From b074060b1907927e6e7cd4cb4cb0a89906b2f14a Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Mon, 25 Nov 2024 15:58:43 +0000 Subject: [PATCH 01/11] Update migration script to handle multiple migrations automatically #425 --- .../migrations/migration.py | 251 ++++++++++++++++-- ...1016101400_expected_lifetime_migration.py} | 1 + ...41125102300_number_of_spares_migration.py} | 1 + ...ple_migration.py => _example_migration.py} | 0 4 files changed, 224 insertions(+), 29 deletions(-) rename inventory_management_system_api/migrations/scripts/{expected_lifetime_migration.py => 20241016101400_expected_lifetime_migration.py} (99%) rename inventory_management_system_api/migrations/scripts/{number_of_spares_migration.py => 20241125102300_number_of_spares_migration.py} (99%) rename inventory_management_system_api/migrations/scripts/{example_migration.py => _example_migration.py} (100%) diff --git a/inventory_management_system_api/migrations/migration.py b/inventory_management_system_api/migrations/migration.py index f28a391f..b34ac1a6 100644 --- a/inventory_management_system_api/migrations/migration.py +++ b/inventory_management_system_api/migrations/migration.py @@ -1,9 +1,12 @@ """Module for providing a migration script""" import argparse +import datetime import importlib import logging +import sys from abc import ABC, abstractmethod +from typing import Optional from pymongo.client_session import ClientSession from pymongo.database import Database @@ -64,8 +67,178 @@ def load_migration(name: str) -> BaseMigration: migration_module = importlib.import_module(f"inventory_management_system_api.migrations.scripts.{name}") migration_class = getattr(migration_module, "Migration", None) + return migration_class(get_database()) + + +def find_available_migrations() -> list[str]: + """Find and returns a sorted list of names of the available migrations""" + + with importlib.resources.path("inventory_management_system_api.migrations.scripts", "") as scripts_path: + files_in_scripts = list(scripts_path.iterdir()) + available_migrations = list( + filter(lambda name: not name.startswith("_"), [file.name.replace(".py", "") for file in files_in_scripts]) + ) + return sorted(available_migrations) + + +def load_available_migrations() -> list[BaseMigration]: + """Find and returns a sorted list of the available migrations""" + + return [load_migration(name) for name in find_available_migrations()] + + +def find_last_migration_applied() -> Optional[str]: + """Returns the name of the last migration applied to the database (or None if no migration has ever been applied)""" + database = get_database() - return migration_class(database) + migrations_collection = database.schema_migrations + last_migration_document = migrations_collection.find_one({"_id": "last_migration"}) + + if not last_migration_document: + return None + return last_migration_document["name"] + + +def set_last_migration_applied(name: str) -> None: + """Assigns the value of the last migration applied""" + + database = get_database() + migrations_collection = database.schema_migrations + migrations_collection.update_one({"_id": "last_migration"}, {"$set": {"name": name}}, upsert=True) + + +def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: + """ + Returns a list of forward migrations that need to be applied to get from the existing database version to the + given one + + :param name: Name of the last forward migration to apply. 'latest' is used to indicate just use the latest one. + :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order + they should be applied. + """ + + available_migrations = find_available_migrations() + + start_index = 0 + + last_migration = find_last_migration_applied() + if last_migration: + try: + start_index = available_migrations.index(last_migration) + except ValueError: + logger.warning( + "Last migration applied '%s' not found in current migrations. Have you skipped a version?", + last_migration, + ) + + end_index = len(available_migrations) - 1 + if name != "latest": + try: + end_index = available_migrations.index(name) + except ValueError: + sys.exit(f"Migration '{name}' was not found in the available list of migrations") + + if start_index >= end_index: + sys.exit( + f"Migration '{name}' is either the same or before the last migration applied '{last_migration}. " + "So there is nothing to migrate.'" + ) + + return {name: load_migration(name) for name in available_migrations[start_index : end_index + 1]} + + +def load_backward_migrations_to(name: str) -> dict[str, BaseMigration]: + """Returns a list of backward migrations that need to be applied to get from the existing database version to the + given one + + :param name: Name of the last backward migration to apply. + :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order + they should be applied. + """ + + available_migrations = find_available_migrations() + + start_index = 0 + + last_migration = find_last_migration_applied() + if last_migration: + try: + start_index = available_migrations.index(last_migration) + except ValueError: + sys.exit( + f"Last migration '{last_migration}' applied not found in current migrations. " + "Have you skipped a version?" + ) + else: + sys.exit("No migrations to revert.") + + try: + end_index = available_migrations.index(name) + except ValueError: + sys.exit(f"Migration '{name}' was not found in the available list of migrations") + + if start_index <= end_index: + sys.exit( + f"Migration '{name}' is either the same or after the last migration applied '{last_migration}. " + "So there is nothing to migrate.'" + ) + + return {name: load_migration(name) for name in available_migrations[start_index : end_index - 1 : -1]} + + +class CommandGenerate(SubCommand): + """Command that generates a new migration file""" + + def __init__(self): + super().__init__(help_message="Generates a new migration file") + + def setup(self, parser: argparse.ArgumentParser): + parser.add_argument("name", help="Name of the migration to add") + parser.add_argument("description", help="Description of the migration to add") + + def run(self, args: argparse.Namespace): + current_time = datetime.datetime.now(datetime.UTC) + file_name = f"{f"{current_time:%Y%m%d%H%M%S}"}_{args.name}.py" + with open(f"inventory_management_system_api/migrations/scripts/{file_name}", "w", encoding="utf-8") as file: + file.write( + f'''""" +Module providing a migration that {args.description} +""" + +# Expect some duplicate code inside migrations as models can be duplicated +# pylint: disable=invalid-name +# pylint: disable=duplicate-code + +import logging +from typing import Collection + +from pymongo.client_session import ClientSession +from pymongo.database import Database + +from inventory_management_system_api.migrations.migration import BaseMigration + +logger = logging.getLogger() + + +class Migration(BaseMigration): + """Migration that {args.description}""" + + description = "{args.description}" + + def __init__(self, database: Database): + pass + + def forward(self, session: ClientSession): + """Applies database changes.""" + + logger.info("{args.name} forward migration") + + def backward(self, session: ClientSession): + """Reverses database changes.""" + + logger.info("{args.name} backward migration") +''' + ) class CommandList(SubCommand): @@ -78,12 +251,7 @@ def setup(self, parser: argparse.ArgumentParser): pass def run(self, args: argparse.Namespace): - # Find a list of all available migration scripts - with importlib.resources.path("inventory_management_system_api.migrations.scripts", "") as scripts_path: - files_in_scripts = list(scripts_path.iterdir()) - available_migrations = list( - filter(lambda name: "__" not in name, [file.name.replace(".py", "") for file in files_in_scripts]) - ) + available_migrations = find_available_migrations() for migration_name in available_migrations: migration = load_migration(migration_name) @@ -97,19 +265,31 @@ def __init__(self): super().__init__(help_message="Performs a forward database migration") def setup(self, parser: argparse.ArgumentParser): - parser.add_argument("migration", help="Name of the migration to perform") + parser.add_argument("name", help="Name of the migration to migrate forwards to (inclusive)") def run(self, args: argparse.Namespace): - migration_instance: BaseMigration = load_migration(args.migration) - - # Run migration inside a session to lock writes and revert the changes if it fails - with mongodb_client.start_session() as session: - with session.start_transaction(): - logger.info("Performing forward migration...") - migration_instance.forward(session) - # Run some things outside the transaction e.g. if needing to drop a collection - migration_instance.forward_after_transaction(session) - logger.info("Done!") + forward_migrations = load_forward_migrations_to(args.name) + + print("This operation will apply the following migrations:") + for name in forward_migrations.keys(): + print(name) + + print() + answer = input("Are you sure you wish to proceed? ") + if answer in ("y", "yes"): + # Run migration inside a session to lock writes and revert the changes if it fails + with mongodb_client.start_session() as session: + with session.start_transaction(): + for name, migration in forward_migrations.items(): + logger.info("Performing forward migration for '%s'...", name) + migration.forward(session) + set_last_migration_applied(list(forward_migrations.keys())[-1]) + # Run some things outside the transaction e.g. if needing to drop a collection + for name, migration in forward_migrations.items(): + logger.info("Finalising forward migration for '%s'...", name) + migration.forward_after_transaction(session) + + logger.info("Done!") class CommandBackward(SubCommand): @@ -119,23 +299,36 @@ def __init__(self): super().__init__(help_message="Performs a backward database migration") def setup(self, parser: argparse.ArgumentParser): - parser.add_argument("migration", help="Name of the migration to revert") + parser.add_argument("name", help="Name migration to migrate backwards to (inclusive).") def run(self, args: argparse.Namespace): - migration_instance: BaseMigration = load_migration(args.migration) - - # Run migration inside a session to lock writes and revert the changes if it fails - with mongodb_client.start_session() as session: - with session.start_transaction(): - logger.info("Performing backward migration...") - migration_instance.backward(session) - # Run some things outside the transaction e.g. if needing to drop a collection - migration_instance.backward_after_transaction(session) - logger.info("Done!") + backward_migrations = load_backward_migrations_to(args.name) + + print("This operation will apply the following migrations:") + for name in backward_migrations.keys(): + print(name) + + print() + answer = input("Are you sure you wish to proceed? ") + if answer in ("y", "yes"): + # Run migration inside a session to lock writes and revert the changes if it fails + with mongodb_client.start_session() as session: + with session.start_transaction(): + for name, migration in backward_migrations.items(): + logger.info("Performing backward migration for '%s'...", name) + migration.backward(session) + set_last_migration_applied(list(backward_migrations.keys())[-1]) + # Run some things outside the transaction e.g. if needing to drop a collection + for name, migration in backward_migrations.items(): + logger.info("Finalising backward migration for '%s'...", name) + migration.backward_after_transaction(session) + + logger.info("Done!") # List of subcommands commands: dict[str, SubCommand] = { + "generate": CommandGenerate(), "list": CommandList(), "forward": CommandForward(), "backward": CommandBackward(), diff --git a/inventory_management_system_api/migrations/scripts/expected_lifetime_migration.py b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py similarity index 99% rename from inventory_management_system_api/migrations/scripts/expected_lifetime_migration.py rename to inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py index 4587f07a..8c31250b 100644 --- a/inventory_management_system_api/migrations/scripts/expected_lifetime_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py @@ -3,6 +3,7 @@ """ # Expect some duplicate code inside migrations as models can be duplicated +# pylint: disable=invalid-name # pylint: disable=duplicate-code import logging diff --git a/inventory_management_system_api/migrations/scripts/number_of_spares_migration.py b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py similarity index 99% rename from inventory_management_system_api/migrations/scripts/number_of_spares_migration.py rename to inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py index eb3318b0..8708a866 100644 --- a/inventory_management_system_api/migrations/scripts/number_of_spares_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py @@ -3,6 +3,7 @@ """ # Expect some duplicate code inside migrations as models can be duplicated +# pylint: disable=invalid-name # pylint: disable=duplicate-code import logging diff --git a/inventory_management_system_api/migrations/scripts/example_migration.py b/inventory_management_system_api/migrations/scripts/_example_migration.py similarity index 100% rename from inventory_management_system_api/migrations/scripts/example_migration.py rename to inventory_management_system_api/migrations/scripts/_example_migration.py From f5dc7111effacc51d971ccc7dd9ba0118dab5774 Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Tue, 26 Nov 2024 12:45:12 +0000 Subject: [PATCH 02/11] Clean up script and add status and set commands #425 --- .../migrations/base.py | 40 ++ .../migrations/core.py | 210 +++++++++++ .../migrations/migration.py | 353 ------------------ .../migrations/script.py | 242 ++++++++++++ ...41016101400_expected_lifetime_migration.py | 2 +- ...241125102300_number_of_spares_migration.py | 2 +- .../migrations/scripts/_example_migration.py | 2 +- pyproject.toml | 2 +- 8 files changed, 496 insertions(+), 357 deletions(-) create mode 100644 inventory_management_system_api/migrations/base.py create mode 100644 inventory_management_system_api/migrations/core.py delete mode 100644 inventory_management_system_api/migrations/migration.py create mode 100644 inventory_management_system_api/migrations/script.py diff --git a/inventory_management_system_api/migrations/base.py b/inventory_management_system_api/migrations/base.py new file mode 100644 index 00000000..2e6c5bd7 --- /dev/null +++ b/inventory_management_system_api/migrations/base.py @@ -0,0 +1,40 @@ +"""Module for providing the base of a migration script.""" + +from abc import ABC, abstractmethod + +from pymongo.client_session import ClientSession +from pymongo.database import Database + + +class BaseMigration(ABC): + """Base class for a migration with a forward and backward step""" + + @abstractmethod + def __init__(self, database: Database): + pass + + @property + @abstractmethod + def description(self) -> str: + """Description of this migration""" + return "" + + @abstractmethod + def forward(self, session: ClientSession): + """Method for executing the migration""" + + def forward_after_transaction(self, session: ClientSession): + """Method called after the forward function is called to do anything that can't be done inside a transaction + (ONLY USE IF NECESSARY e.g. dropping a collection)""" + + @abstractmethod + def backward(self, session: ClientSession): + """Method for reversing the migration""" + + def backward_after_transaction(self, session: ClientSession): + """ + Method called after the backward function is called to do anything that can't be done inside a transaction + (ONLY USE IF NECESSARY e.g. dropping a collection) + + Note that this can run after other migrations as well so should not interfere with them. + """ diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py new file mode 100644 index 00000000..70bbd8cc --- /dev/null +++ b/inventory_management_system_api/migrations/core.py @@ -0,0 +1,210 @@ +"""Module for providing the core functionality for database migrations.""" + +import importlib +import logging +import sys +from typing import Optional + +from inventory_management_system_api.core.database import get_database, mongodb_client +from inventory_management_system_api.migrations.base import BaseMigration + +database = get_database() +logger = logging.getLogger() + + +def load_migration(name: str) -> BaseMigration: + """ + Loads a migration script from the scripts module. + + :param name: Name of the migration script to load. + """ + + migration_module = importlib.import_module(f"inventory_management_system_api.migrations.scripts.{name}") + migration_class = getattr(migration_module, "Migration", None) + + return migration_class(database) + + +def find_available_migrations() -> list[str]: + """ + Find and returns a sorted list of names of the available migrations. + + :returns: Sorted list of the names of the available migrations found (in chronological order). + """ + + with importlib.resources.path("inventory_management_system_api.migrations.scripts", "") as scripts_path: + files_in_scripts = list(scripts_path.iterdir()) + available_migrations = list( + filter(lambda name: not name.startswith("_"), [file.name.replace(".py", "") for file in files_in_scripts]) + ) + return sorted(available_migrations) + + +def get_last_migration_applied() -> Optional[str]: + """ + Obtain the name of the last migration applied to the database. + + :return: Either the name of the last migration applied to the database or `None` if no migration has ever been + applied. + """ + + migrations_collection = database.database_migrations + last_migration_document = migrations_collection.find_one({"_id": "last_migration"}) + + if not last_migration_document: + return None + return last_migration_document["name"] + + +def set_last_migration_applied(name: str) -> None: + """ + Assigns the name of the of the last migration applied to the database. + + :param name: The name of the last migration applied to the database. + """ + + migrations_collection = database.database_migrations + migrations_collection.update_one({"_id": "last_migration"}, {"$set": {"name": name}}, upsert=True) + + +def find_migration_index(name: str, migration_names: list[str]) -> int: + """ + Returns the index of a specific migration name in a list of sorted migration names. + + :param name: Name of the migration to look for. A value of 'latest' indicates the last available one should be used + instead. + :param migration_names: List of migration names. + :return: Index of the found migration in the `migration_names` list. + :raises: ValueError if the `name` does not appear in `migration_names`. + """ + + if name == "latest": + return len(migration_names) - 1 + return migration_names.index(name) + + +def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: + """ + Returns a list of forward migrations that need to be applied to get from the last migration applied to the database + to the given one. + + :param name: Name of the last forward migration to apply. 'latest' will just use the latest one. + :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order + they should be applied. + """ + + available_migrations = find_available_migrations() + + start_index = 0 + + last_migration = get_last_migration_applied() + if last_migration: + try: + start_index = find_migration_index(last_migration, available_migrations) + except ValueError: + logger.warning( + "Last migration applied '%s' not found in current migrations. Have you skipped a version?", + last_migration, + ) + + try: + end_index = find_migration_index(name, available_migrations) + except ValueError: + sys.exit(f"Migration '{name}' was not found in the available list of migrations") + + if start_index >= end_index: + sys.exit( + f"Migration '{name}' is either the same or before the last migration applied '{last_migration}. " + "So there is nothing to migrate.'" + ) + + # Dicts are insertion ordered so will match the list order + return {name: load_migration(name) for name in available_migrations[start_index : end_index + 1]} + + +def load_backward_migrations_to(name: str) -> dict[str, BaseMigration]: + """ + Returns a list of forward migrations that need to be applied to get from the last migration applied to the database + to the given one. + + :param name: Name of the last backward migration to apply. + :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order + they should be applied. + """ + + available_migrations = find_available_migrations() + + start_index = 0 + + last_migration = get_last_migration_applied() + if last_migration: + try: + start_index = find_migration_index(last_migration, available_migrations) + except ValueError: + sys.exit( + f"Last migration '{last_migration}' applied not found in current migrations. " + "Have you skipped a version?" + ) + else: + sys.exit("No migrations to revert.") + + try: + end_index = find_migration_index(name, available_migrations) + except ValueError: + sys.exit(f"Migration '{name}' was not found in the available list of migrations") + + if start_index <= end_index: + sys.exit( + f"Migration '{name}' is either the same or after the last migration applied '{last_migration}. " + "So there is nothing to migrate.'" + ) + + # Dicts are insertion ordered so will match the list order + return {name: load_migration(name) for name in available_migrations[start_index : end_index - 1 : -1]} + + +def execute_forward_migrations(migrations: dict[str, BaseMigration]) -> None: + """ + Executes a list of forward migrations in order. + + All `forward_after_transaction`'s are executed AFTER the all of the `forward`'s are executed. This is so that the + latter can be done at once in a transaction. + + :param migrations: List of dicts containing the names and instances of the migrations that need to be applied in the + order they should be applied. + """ + + # Run migration inside a session to lock writes and revert the changes if it fails + with mongodb_client.start_session() as session: + with session.start_transaction(): + for name, migration in migrations.items(): + logger.info("Performing forward migration for '%s'...", name) + migration.forward(session) + set_last_migration_applied(list(migrations.keys())[-1]) + # Run some things outside the transaction e.g. if needing to drop a collection + for name, migration in migrations.items(): + logger.info("Finalising forward migration for '%s'...", name) + migration.forward_after_transaction(session) + + +def execute_backward_migrations(migrations: dict[str, BaseMigration]): + """ + Executes a list of backward migrations in order. + + All `backward_after_transaction`'s are executed AFTER the all of the `backward`'s are executed. This is so that the + latter can be done at once in a transaction. + + :param migrations: List of dicts containing the names and instances of the migrations that need to be applied in the + order they should be applied. + """ + # Run migration inside a session to lock writes and revert the changes if it fails + with mongodb_client.start_session() as session: + with session.start_transaction(): + for name, migration in migrations.items(): + logger.info("Performing backward migration for '%s'...", name) + migration.backward(session) + set_last_migration_applied(list(migrations.keys())[-1]) + # Run some things outside the transaction e.g. if needing to drop a collection + for name, migration in migrations.items(): + logger.info("Finalising backward migration for '%s'...", name) + migration.backward_after_transaction(session) diff --git a/inventory_management_system_api/migrations/migration.py b/inventory_management_system_api/migrations/migration.py deleted file mode 100644 index b34ac1a6..00000000 --- a/inventory_management_system_api/migrations/migration.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Module for providing a migration script""" - -import argparse -import datetime -import importlib -import logging -import sys -from abc import ABC, abstractmethod -from typing import Optional - -from pymongo.client_session import ClientSession -from pymongo.database import Database - -from inventory_management_system_api.core.database import get_database, mongodb_client - -logger = logging.getLogger() - - -class BaseMigration(ABC): - """Base class for a migration with a forward and backward step""" - - @abstractmethod - def __init__(self, database: Database): - pass - - @property - @abstractmethod - def description(self) -> str: - """Description of this migration""" - return "" - - @abstractmethod - def forward(self, session: ClientSession): - """Method for executing the migration""" - - def forward_after_transaction(self, session: ClientSession): - """Method called after the forward function is called to do anything that can't be done inside a transaction - (ONLY USE IF NECESSARY e.g. dropping a collection)""" - - @abstractmethod - def backward(self, session: ClientSession): - """Method for reversing the migration""" - - def backward_after_transaction(self, session: ClientSession): - """Method called after the backward function is called to do anything that can't be done inside a transaction - (ONLY USE IF NECESSARY e.g. dropping a collection)""" - - -class SubCommand(ABC): - """Base class for a sub command""" - - def __init__(self, help_message: str): - self.help_message = help_message - - @abstractmethod - def setup(self, parser: argparse.ArgumentParser): - """Setup the parser by adding any parameters here""" - - @abstractmethod - def run(self, args: argparse.Namespace): - """Run the command with the given parameters as added by 'setup'""" - - -def load_migration(name: str) -> BaseMigration: - """Loads a migration script from the scripts module""" - - migration_module = importlib.import_module(f"inventory_management_system_api.migrations.scripts.{name}") - migration_class = getattr(migration_module, "Migration", None) - - return migration_class(get_database()) - - -def find_available_migrations() -> list[str]: - """Find and returns a sorted list of names of the available migrations""" - - with importlib.resources.path("inventory_management_system_api.migrations.scripts", "") as scripts_path: - files_in_scripts = list(scripts_path.iterdir()) - available_migrations = list( - filter(lambda name: not name.startswith("_"), [file.name.replace(".py", "") for file in files_in_scripts]) - ) - return sorted(available_migrations) - - -def load_available_migrations() -> list[BaseMigration]: - """Find and returns a sorted list of the available migrations""" - - return [load_migration(name) for name in find_available_migrations()] - - -def find_last_migration_applied() -> Optional[str]: - """Returns the name of the last migration applied to the database (or None if no migration has ever been applied)""" - - database = get_database() - migrations_collection = database.schema_migrations - last_migration_document = migrations_collection.find_one({"_id": "last_migration"}) - - if not last_migration_document: - return None - return last_migration_document["name"] - - -def set_last_migration_applied(name: str) -> None: - """Assigns the value of the last migration applied""" - - database = get_database() - migrations_collection = database.schema_migrations - migrations_collection.update_one({"_id": "last_migration"}, {"$set": {"name": name}}, upsert=True) - - -def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: - """ - Returns a list of forward migrations that need to be applied to get from the existing database version to the - given one - - :param name: Name of the last forward migration to apply. 'latest' is used to indicate just use the latest one. - :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order - they should be applied. - """ - - available_migrations = find_available_migrations() - - start_index = 0 - - last_migration = find_last_migration_applied() - if last_migration: - try: - start_index = available_migrations.index(last_migration) - except ValueError: - logger.warning( - "Last migration applied '%s' not found in current migrations. Have you skipped a version?", - last_migration, - ) - - end_index = len(available_migrations) - 1 - if name != "latest": - try: - end_index = available_migrations.index(name) - except ValueError: - sys.exit(f"Migration '{name}' was not found in the available list of migrations") - - if start_index >= end_index: - sys.exit( - f"Migration '{name}' is either the same or before the last migration applied '{last_migration}. " - "So there is nothing to migrate.'" - ) - - return {name: load_migration(name) for name in available_migrations[start_index : end_index + 1]} - - -def load_backward_migrations_to(name: str) -> dict[str, BaseMigration]: - """Returns a list of backward migrations that need to be applied to get from the existing database version to the - given one - - :param name: Name of the last backward migration to apply. - :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order - they should be applied. - """ - - available_migrations = find_available_migrations() - - start_index = 0 - - last_migration = find_last_migration_applied() - if last_migration: - try: - start_index = available_migrations.index(last_migration) - except ValueError: - sys.exit( - f"Last migration '{last_migration}' applied not found in current migrations. " - "Have you skipped a version?" - ) - else: - sys.exit("No migrations to revert.") - - try: - end_index = available_migrations.index(name) - except ValueError: - sys.exit(f"Migration '{name}' was not found in the available list of migrations") - - if start_index <= end_index: - sys.exit( - f"Migration '{name}' is either the same or after the last migration applied '{last_migration}. " - "So there is nothing to migrate.'" - ) - - return {name: load_migration(name) for name in available_migrations[start_index : end_index - 1 : -1]} - - -class CommandGenerate(SubCommand): - """Command that generates a new migration file""" - - def __init__(self): - super().__init__(help_message="Generates a new migration file") - - def setup(self, parser: argparse.ArgumentParser): - parser.add_argument("name", help="Name of the migration to add") - parser.add_argument("description", help="Description of the migration to add") - - def run(self, args: argparse.Namespace): - current_time = datetime.datetime.now(datetime.UTC) - file_name = f"{f"{current_time:%Y%m%d%H%M%S}"}_{args.name}.py" - with open(f"inventory_management_system_api/migrations/scripts/{file_name}", "w", encoding="utf-8") as file: - file.write( - f'''""" -Module providing a migration that {args.description} -""" - -# Expect some duplicate code inside migrations as models can be duplicated -# pylint: disable=invalid-name -# pylint: disable=duplicate-code - -import logging -from typing import Collection - -from pymongo.client_session import ClientSession -from pymongo.database import Database - -from inventory_management_system_api.migrations.migration import BaseMigration - -logger = logging.getLogger() - - -class Migration(BaseMigration): - """Migration that {args.description}""" - - description = "{args.description}" - - def __init__(self, database: Database): - pass - - def forward(self, session: ClientSession): - """Applies database changes.""" - - logger.info("{args.name} forward migration") - - def backward(self, session: ClientSession): - """Reverses database changes.""" - - logger.info("{args.name} backward migration") -''' - ) - - -class CommandList(SubCommand): - """Command that lists available database migrations""" - - def __init__(self): - super().__init__(help_message="List all available database migrations") - - def setup(self, parser: argparse.ArgumentParser): - pass - - def run(self, args: argparse.Namespace): - available_migrations = find_available_migrations() - for migration_name in available_migrations: - migration = load_migration(migration_name) - - print(f"{migration_name} - {migration.description}") - - -class CommandForward(SubCommand): - """Command that performs a forward database migration""" - - def __init__(self): - super().__init__(help_message="Performs a forward database migration") - - def setup(self, parser: argparse.ArgumentParser): - parser.add_argument("name", help="Name of the migration to migrate forwards to (inclusive)") - - def run(self, args: argparse.Namespace): - forward_migrations = load_forward_migrations_to(args.name) - - print("This operation will apply the following migrations:") - for name in forward_migrations.keys(): - print(name) - - print() - answer = input("Are you sure you wish to proceed? ") - if answer in ("y", "yes"): - # Run migration inside a session to lock writes and revert the changes if it fails - with mongodb_client.start_session() as session: - with session.start_transaction(): - for name, migration in forward_migrations.items(): - logger.info("Performing forward migration for '%s'...", name) - migration.forward(session) - set_last_migration_applied(list(forward_migrations.keys())[-1]) - # Run some things outside the transaction e.g. if needing to drop a collection - for name, migration in forward_migrations.items(): - logger.info("Finalising forward migration for '%s'...", name) - migration.forward_after_transaction(session) - - logger.info("Done!") - - -class CommandBackward(SubCommand): - """Command that performs a backward database migration""" - - def __init__(self): - super().__init__(help_message="Performs a backward database migration") - - def setup(self, parser: argparse.ArgumentParser): - parser.add_argument("name", help="Name migration to migrate backwards to (inclusive).") - - def run(self, args: argparse.Namespace): - backward_migrations = load_backward_migrations_to(args.name) - - print("This operation will apply the following migrations:") - for name in backward_migrations.keys(): - print(name) - - print() - answer = input("Are you sure you wish to proceed? ") - if answer in ("y", "yes"): - # Run migration inside a session to lock writes and revert the changes if it fails - with mongodb_client.start_session() as session: - with session.start_transaction(): - for name, migration in backward_migrations.items(): - logger.info("Performing backward migration for '%s'...", name) - migration.backward(session) - set_last_migration_applied(list(backward_migrations.keys())[-1]) - # Run some things outside the transaction e.g. if needing to drop a collection - for name, migration in backward_migrations.items(): - logger.info("Finalising backward migration for '%s'...", name) - migration.backward_after_transaction(session) - - logger.info("Done!") - - -# List of subcommands -commands: dict[str, SubCommand] = { - "generate": CommandGenerate(), - "list": CommandList(), - "forward": CommandForward(), - "backward": CommandBackward(), -} - - -def main(): - """Entrypoint for the ims-migrate script""" - - parser = argparse.ArgumentParser() - - subparser = parser.add_subparsers(dest="command") - - for command_name, command in commands.items(): - command_parser = subparser.add_parser(command_name, help=command.help_message) - command.setup(command_parser) - - args = parser.parse_args() - - logging.basicConfig(level=logging.INFO) - - commands[args.command].run(args) diff --git a/inventory_management_system_api/migrations/script.py b/inventory_management_system_api/migrations/script.py new file mode 100644 index 00000000..bd3412ab --- /dev/null +++ b/inventory_management_system_api/migrations/script.py @@ -0,0 +1,242 @@ +"""Module for providing migration commands in a script.""" + +import argparse +import datetime +import logging +import sys +from abc import ABC, abstractmethod + +from inventory_management_system_api.core.database import get_database +from inventory_management_system_api.migrations.core import ( + execute_backward_migrations, + execute_forward_migrations, + find_available_migrations, + find_migration_index, + get_last_migration_applied, + load_backward_migrations_to, + load_forward_migrations_to, + load_migration, + set_last_migration_applied, +) + +logger = logging.getLogger() +database = get_database() + + +def check_user_sure() -> bool: + """ + Asks user if they are sure action should proceed and exits if not. + + :return: Whether user is sure. + """ + + answer = input("Are you sure you wish to proceed? ") + return answer in ("y", "yes") + + +class SubCommand(ABC): + """Base class for a sub command.""" + + def __init__(self, help_message: str): + self.help_message = help_message + + @abstractmethod + def setup(self, parser: argparse.ArgumentParser): + """Setup the parser by adding any parameters here.""" + + @abstractmethod + def run(self, args: argparse.Namespace): + """Run the command with the given parameters as added by 'setup'.""" + + +class CommandCreate(SubCommand): + """Command that creates a new migration file.""" + + def __init__(self): + super().__init__(help_message="Creates a new migration file") + + def setup(self, parser: argparse.ArgumentParser): + parser.add_argument("name", help="Name of the migration to create") + parser.add_argument("description", help="Description of the migration to create") + + def run(self, args: argparse.Namespace): + current_time = datetime.datetime.now(datetime.UTC) + file_name = f"{f"{current_time:%Y%m%d%H%M%S}"}_{args.name}.py" + with open(f"inventory_management_system_api/migrations/scripts/{file_name}", "w", encoding="utf-8") as file: + file.write( + f'''""" +Module providing a migration that {args.description} +""" + +# Expect some duplicate code inside migrations as models can be duplicated +# pylint: disable=invalid-name +# pylint: disable=duplicate-code + +import logging + +from pymongo.client_session import ClientSession +from pymongo.database import Database + +from inventory_management_system_api.migrations.base import BaseMigration + +logger = logging.getLogger() + + +class Migration(BaseMigration): + """Migration that {args.description}""" + + description = "{args.description}" + + def __init__(self, database: Database): + pass + + def forward(self, session: ClientSession): + """Applies database changes.""" + + def backward(self, session: ClientSession): + """Reverses database changes.""" +''' + ) + + +class CommandList(SubCommand): + """Command that lists available database migrations.""" + + def __init__(self): + super().__init__(help_message="List all available database migrations") + + def setup(self, parser: argparse.ArgumentParser): + pass + + def run(self, args: argparse.Namespace): + available_migrations = find_available_migrations() + for migration_name in available_migrations: + migration = load_migration(migration_name) + + print(f"{migration_name} - {migration.description}") + + +class CommandStatus(SubCommand): + """Command displays the current database migration status.""" + + def __init__(self): + super().__init__(help_message="Display the status of the current database and available migrations") + + def setup(self, parser: argparse.ArgumentParser): + pass + + def run(self, args: argparse.Namespace): + available_migrations = find_available_migrations() + last_migration_applied = get_last_migration_applied() + + print(f"Last migration applied: {last_migration_applied}") + print() + + for migration_name in available_migrations: + migration = load_migration(migration_name) + + if last_migration_applied == migration_name: + print(f"> {migration_name} - {migration.description}") + else: + print(f" {migration_name} - {migration.description}") + + +class CommandForward(SubCommand): + """Command that performs a forward database migration.""" + + def __init__(self): + super().__init__(help_message="Performs a forward database migration") + + def setup(self, parser: argparse.ArgumentParser): + parser.add_argument( + "name", + help="Name of the migration to migrate forwards to (inclusive). Use 'latest' to " + "update to whatever the current latest is.", + ) + + def run(self, args: argparse.Namespace): + migrations = load_forward_migrations_to(args.name) + + print("This operation will apply the following migrations:") + for name in migrations.keys(): + print(name) + + print() + if check_user_sure(): + execute_forward_migrations(migrations) + logger.info("Done!") + + +class CommandBackward(SubCommand): + """Command that performs a backward database migration.""" + + def __init__(self): + super().__init__(help_message="Performs a backward database migration") + + def setup(self, parser: argparse.ArgumentParser): + parser.add_argument("name", help="Name migration to migrate backwards to (inclusive).") + + def run(self, args: argparse.Namespace): + migrations = load_backward_migrations_to(args.name) + + print("This operation will apply the following migrations:") + for name in migrations.keys(): + print(name) + print() + + if check_user_sure(): + execute_backward_migrations(migrations) + logger.info("Done!") + + +class CommandSet(SubCommand): + """Command that sets the last migration of the database to a specific migration.""" + + def __init__(self): + super().__init__(help_message="Sets the last migration of the database to a specific migration") + + def setup(self, parser: argparse.ArgumentParser): + parser.add_argument("name", help="Name of the last migration the database currently matches.") + + def run(self, args: argparse.Namespace): + available_migrations = find_available_migrations() + + try: + end_index = find_migration_index(args.name, available_migrations) + except ValueError: + sys.exit(f"Migration '{args.name}' was not found in the available list of migrations") + + print(f"This operation will forcibly set the latest migration to '{available_migrations[end_index]}'") + print() + + if check_user_sure(): + set_last_migration_applied(available_migrations[end_index]) + + +# List of subcommands +commands: dict[str, SubCommand] = { + "create": CommandCreate(), + "status": CommandStatus(), + "list": CommandList(), + "forward": CommandForward(), + "backward": CommandBackward(), + "set": CommandSet(), +} + + +def main(): + """Entrypoint for the ims-migrate script.""" + + parser = argparse.ArgumentParser() + + subparser = parser.add_subparsers(dest="command") + + for command_name, command in commands.items(): + command_parser = subparser.add_parser(command_name, help=command.help_message) + command.setup(command_parser) + + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + + commands[args.command].run(args) diff --git a/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py index 8c31250b..1e4d1e16 100644 --- a/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py @@ -13,7 +13,7 @@ from pymongo.client_session import ClientSession from pymongo.database import Database -from inventory_management_system_api.migrations.migration import BaseMigration +from inventory_management_system_api.migrations.base import BaseMigration from inventory_management_system_api.models.catalogue_item import PropertyIn, PropertyOut from inventory_management_system_api.models.custom_object_id_data_types import CustomObjectIdField, StringObjectIdField from inventory_management_system_api.models.mixins import CreatedModifiedTimeInMixin, CreatedModifiedTimeOutMixin diff --git a/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py index 8708a866..da0121d0 100644 --- a/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py @@ -13,7 +13,7 @@ from pymongo.client_session import ClientSession from pymongo.database import Database -from inventory_management_system_api.migrations.migration import BaseMigration +from inventory_management_system_api.migrations.base import BaseMigration from inventory_management_system_api.models.catalogue_item import PropertyIn, PropertyOut from inventory_management_system_api.models.custom_object_id_data_types import CustomObjectIdField, StringObjectIdField from inventory_management_system_api.models.mixins import CreatedModifiedTimeInMixin, CreatedModifiedTimeOutMixin diff --git a/inventory_management_system_api/migrations/scripts/_example_migration.py b/inventory_management_system_api/migrations/scripts/_example_migration.py index c8dcac99..ea020a10 100644 --- a/inventory_management_system_api/migrations/scripts/_example_migration.py +++ b/inventory_management_system_api/migrations/scripts/_example_migration.py @@ -11,7 +11,7 @@ from pymongo.client_session import ClientSession from pymongo.database import Database -from inventory_management_system_api.migrations.migration import BaseMigration +from inventory_management_system_api.migrations.base import BaseMigration logger = logging.getLogger() diff --git a/pyproject.toml b/pyproject.toml index 3ecd0217..7058d893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "Repository" = "https://github.com/ral-facilities/inventory-management-system-api" [project.scripts] -"ims-migrate" = "inventory_management_system_api.migrations.migration:main" +"ims-migrate" = "inventory_management_system_api.migrations.script:main" [project.optional-dependencies] code-analysis = [ From e51c4db867a283c5b7148a9e5bb16edfa9e17fc4 Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Tue, 26 Nov 2024 14:14:25 +0000 Subject: [PATCH 03/11] Fix some issues and rename the field stored #425 --- .../migrations/core.py | 83 +++++++++++-------- .../migrations/script.py | 24 +++--- .../scripts/20241126124931_test_migration.py | 31 +++++++ 3 files changed, 91 insertions(+), 47 deletions(-) create mode 100644 inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py index 70bbd8cc..a5af64a5 100644 --- a/inventory_management_system_api/migrations/core.py +++ b/inventory_management_system_api/migrations/core.py @@ -40,31 +40,32 @@ def find_available_migrations() -> list[str]: return sorted(available_migrations) -def get_last_migration_applied() -> Optional[str]: +def get_previous_migration() -> Optional[str]: """ - Obtain the name of the last migration applied to the database. + Obtain the name of the last forward migration that gets the database to its current state. - :return: Either the name of the last migration applied to the database or `None` if no migration has ever been - applied. + :return: Either the name of the last forward migration applied to the database or `None` if no migration has ever + been applied. """ migrations_collection = database.database_migrations - last_migration_document = migrations_collection.find_one({"_id": "last_migration"}) + previous_migration_document = migrations_collection.find_one({"_id": "previous_migration"}) - if not last_migration_document: + if not previous_migration_document: return None - return last_migration_document["name"] + return previous_migration_document["name"] -def set_last_migration_applied(name: str) -> None: +def set_previous_migration(name: Optional[str]) -> None: """ - Assigns the name of the of the last migration applied to the database. + Assigns the name of the of the previous migration that got the database to its current state inside the database. - :param name: The name of the last migration applied to the database. + :param name: The name of the previous migration applied to the database or `None` if being set back no migrations + having been applied. """ migrations_collection = database.database_migrations - migrations_collection.update_one({"_id": "last_migration"}, {"$set": {"name": name}}, upsert=True) + migrations_collection.update_one({"_id": "last_forward_migration"}, {"$set": {"name": name}}, upsert=True) def find_migration_index(name: str, migration_names: list[str]) -> int: @@ -86,7 +87,7 @@ def find_migration_index(name: str, migration_names: list[str]) -> int: def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: """ Returns a list of forward migrations that need to be applied to get from the last migration applied to the database - to the given one. + to the given one inclusive. :param name: Name of the last forward migration to apply. 'latest' will just use the latest one. :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order @@ -97,14 +98,14 @@ def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: start_index = 0 - last_migration = get_last_migration_applied() - if last_migration: + previous_migration = get_previous_migration() + if previous_migration: try: - start_index = find_migration_index(last_migration, available_migrations) + start_index = find_migration_index(previous_migration, available_migrations) + 1 except ValueError: logger.warning( - "Last migration applied '%s' not found in current migrations. Have you skipped a version?", - last_migration, + "Previous migration applied '%s' not found in current migrations. Have you skipped a version?", + previous_migration, ) try: @@ -112,55 +113,65 @@ def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: except ValueError: sys.exit(f"Migration '{name}' was not found in the available list of migrations") - if start_index >= end_index: + if start_index > end_index: sys.exit( - f"Migration '{name}' is either the same or before the last migration applied '{last_migration}. " - "So there is nothing to migrate.'" + f"Migration '{name}' is before the previous migration applied '{previous_migration}'. So there is nothing " + "to migrate." ) # Dicts are insertion ordered so will match the list order return {name: load_migration(name) for name in available_migrations[start_index : end_index + 1]} -def load_backward_migrations_to(name: str) -> dict[str, BaseMigration]: +def load_backward_migrations_to(name: str) -> tuple[dict[str, BaseMigration], Optional[str]]: """ - Returns a list of forward migrations that need to be applied to get from the last migration applied to the database - to the given one. + Returns a list of backward migrations that need to be applied to get from the last migration applied to the database + to the given one inclusive. :param name: Name of the last backward migration to apply. - :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order - they should be applied. + :returns: Tuple containing: + - List of dicts containing the names and instances of the migrations that need to be applied in the order + they should be applied. + - Either the name of the last migration before the one given or `None` if there aren't any. """ available_migrations = find_available_migrations() start_index = 0 - last_migration = get_last_migration_applied() - if last_migration: + previous_migration = get_previous_migration() + if previous_migration is not None: try: - start_index = find_migration_index(last_migration, available_migrations) + start_index = find_migration_index(previous_migration, available_migrations) except ValueError: sys.exit( - f"Last migration '{last_migration}' applied not found in current migrations. " + f"Previous migration applied '{previous_migration}' not found in current migrations. " "Have you skipped a version?" ) else: sys.exit("No migrations to revert.") try: - end_index = find_migration_index(name, available_migrations) + end_index = find_migration_index(name, available_migrations) - 1 except ValueError: sys.exit(f"Migration '{name}' was not found in the available list of migrations") if start_index <= end_index: sys.exit( - f"Migration '{name}' is either the same or after the last migration applied '{last_migration}. " + f"Migration '{name}' is already reverted or after the previous migration applied '{previous_migration}. " "So there is nothing to migrate.'" ) + final_previous_migration_name = available_migrations[end_index] if end_index >= 0 else None + + # Array split excludes the end + if end_index < 0: + end_index = None + # Dicts are insertion ordered so will match the list order - return {name: load_migration(name) for name in available_migrations[start_index : end_index - 1 : -1]} + return { + name: load_migration(name) for name in available_migrations[start_index:end_index:-1] + }, final_previous_migration_name def execute_forward_migrations(migrations: dict[str, BaseMigration]) -> None: @@ -180,14 +191,14 @@ def execute_forward_migrations(migrations: dict[str, BaseMigration]) -> None: for name, migration in migrations.items(): logger.info("Performing forward migration for '%s'...", name) migration.forward(session) - set_last_migration_applied(list(migrations.keys())[-1]) + set_previous_migration(list(migrations.keys())[-1]) # Run some things outside the transaction e.g. if needing to drop a collection for name, migration in migrations.items(): logger.info("Finalising forward migration for '%s'...", name) migration.forward_after_transaction(session) -def execute_backward_migrations(migrations: dict[str, BaseMigration]): +def execute_backward_migrations(migrations: dict[str, BaseMigration], final_previous_migration_name: Optional[str]): """ Executes a list of backward migrations in order. @@ -196,6 +207,8 @@ def execute_backward_migrations(migrations: dict[str, BaseMigration]): :param migrations: List of dicts containing the names and instances of the migrations that need to be applied in the order they should be applied. + :param final_previous_migration_name: Either the name of the previous migration before the ones given or `None` if + there aren't any. """ # Run migration inside a session to lock writes and revert the changes if it fails with mongodb_client.start_session() as session: @@ -203,7 +216,7 @@ def execute_backward_migrations(migrations: dict[str, BaseMigration]): for name, migration in migrations.items(): logger.info("Performing backward migration for '%s'...", name) migration.backward(session) - set_last_migration_applied(list(migrations.keys())[-1]) + set_previous_migration(final_previous_migration_name) # Run some things outside the transaction e.g. if needing to drop a collection for name, migration in migrations.items(): logger.info("Finalising backward migration for '%s'...", name) diff --git a/inventory_management_system_api/migrations/script.py b/inventory_management_system_api/migrations/script.py index bd3412ab..20c8f8f2 100644 --- a/inventory_management_system_api/migrations/script.py +++ b/inventory_management_system_api/migrations/script.py @@ -12,11 +12,11 @@ execute_forward_migrations, find_available_migrations, find_migration_index, - get_last_migration_applied, + get_previous_migration, load_backward_migrations_to, load_forward_migrations_to, load_migration, - set_last_migration_applied, + set_previous_migration, ) logger = logging.getLogger() @@ -127,18 +127,18 @@ def setup(self, parser: argparse.ArgumentParser): def run(self, args: argparse.Namespace): available_migrations = find_available_migrations() - last_migration_applied = get_last_migration_applied() + previous_migration = get_previous_migration() - print(f"Last migration applied: {last_migration_applied}") + print(f"Previous migration: {previous_migration}") print() for migration_name in available_migrations: migration = load_migration(migration_name) - if last_migration_applied == migration_name: - print(f"> {migration_name} - {migration.description}") - else: - print(f" {migration_name} - {migration.description}") + print(f" {migration_name} - {migration.description}") + + if previous_migration == migration_name: + print("> Database") class CommandForward(SubCommand): @@ -177,15 +177,15 @@ def setup(self, parser: argparse.ArgumentParser): parser.add_argument("name", help="Name migration to migrate backwards to (inclusive).") def run(self, args: argparse.Namespace): - migrations = load_backward_migrations_to(args.name) + migrations, final_previous_migration_name = load_backward_migrations_to(args.name) - print("This operation will apply the following migrations:") + print("This operation will revert the following migrations:") for name in migrations.keys(): print(name) print() if check_user_sure(): - execute_backward_migrations(migrations) + execute_backward_migrations(migrations, final_previous_migration_name) logger.info("Done!") @@ -210,7 +210,7 @@ def run(self, args: argparse.Namespace): print() if check_user_sure(): - set_last_migration_applied(available_migrations[end_index]) + set_previous_migration(available_migrations[end_index]) # List of subcommands diff --git a/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py b/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py new file mode 100644 index 00000000..3c12bf87 --- /dev/null +++ b/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py @@ -0,0 +1,31 @@ +""" +Module providing a migration that Does nothing +""" + +# Expect some duplicate code inside migrations as models can be duplicated +# pylint: disable=invalid-name +# pylint: disable=duplicate-code + +import logging + +from pymongo.client_session import ClientSession +from pymongo.database import Database + +from inventory_management_system_api.migrations.base import BaseMigration + +logger = logging.getLogger() + + +class Migration(BaseMigration): + """Migration that Does nothing""" + + description = "Does nothing" + + def __init__(self, database: Database): + pass + + def forward(self, session: ClientSession): + """Applies database changes.""" + + def backward(self, session: ClientSession): + """Reverses database changes.""" From b40a965cc5f6962e84a6b6d4b9147f3bdabfa38b Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Tue, 26 Nov 2024 15:27:22 +0000 Subject: [PATCH 04/11] Update README instructions and set migration state in dev_cli db-generate #425 --- README.md | 52 +++++++++++++++--- data/mock_data.dump | Bin 178540 -> 179050 bytes .../migrations/core.py | 2 +- .../migrations/script.py | 26 +++++++-- scripts/dev_cli.py | 4 ++ 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6cff0efb..221a8529 100644 --- a/README.md +++ b/README.md @@ -329,30 +329,64 @@ a microservice that provides user authentication against an LDAP server and retu ### Migrations -Migration scripts are located inside the `inventory_management_system/migrations/scripts`. See the -`example_migration.py` for an example on how to implement one. Any new migrations added should be automatically picked -up and shown via +#### Adding a migration + +To add a migration first use + +```bash +ims-migrate create +``` + +to create a new one inside the `inventory_management_system/migrations/scripts` directory. Then add the code necessary +to perform the migration. See `_example_migration.py` for an example on how to implement one. + +#### Performing forward migrations + +Before performing a you can first check the current status of the database and any outstanding migrations using ```bash -ims-migrate list +ims-migrate status ``` -or +or in Docker ```bash docker exec -it inventory_management_system_api_container ims-migrate list ``` -if running in Docker. +Then to perform all outstanding migrations up to the latest one use + +```bash +ims-migrate forward latest +``` -To perform a migration you should use +You may also specify a specific migration name to apply instead which will apply all migrations between the current +applied one and the specified one. A prompt will be shown to ensure the migrations being applied are sensible. + +#### Performing backward migrations + +To revert the database by performing backwards migrations you can first use ```bash -ims-migrate forward +ims-migrate status ``` -To revert the same migration use +to check the current status of the database and available migrations and then use ```bash ims-migrate backward ``` + +to perform all backward migrations to get from the current database state back to the state prior to the chosen +migration name (i.e. it also performs the backward migration for the given migration name). + +#### Forcing migration state + +If for some reason the migration state is different to what you expect it may be forced via + +```bash +ims-migrate set +``` + +This is already set to `latest` automatically when using the `dev_cli` to regenerate mock data so that the dump retains +the expected state. diff --git a/data/mock_data.dump b/data/mock_data.dump index a3cacd1cd6653aa2630784124083a30a0d5984ff..e699dc9efb43d1d96906ae25fe0f64497dbc24a4 100644 GIT binary patch literal 179050 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∨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 M_{6C%^@z{ymgXpDB-z#qnn@ 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 diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py index a5af64a5..f356e774 100644 --- a/inventory_management_system_api/migrations/core.py +++ b/inventory_management_system_api/migrations/core.py @@ -65,7 +65,7 @@ def set_previous_migration(name: Optional[str]) -> None: """ migrations_collection = database.database_migrations - migrations_collection.update_one({"_id": "last_forward_migration"}, {"$set": {"name": name}}, upsert=True) + migrations_collection.update_one({"_id": "previous_migration"}, {"$set": {"name": name}}, upsert=True) def find_migration_index(name: str, migration_names: list[str]) -> int: diff --git a/inventory_management_system_api/migrations/script.py b/inventory_management_system_api/migrations/script.py index 20c8f8f2..bced569d 100644 --- a/inventory_management_system_api/migrations/script.py +++ b/inventory_management_system_api/migrations/script.py @@ -5,6 +5,7 @@ import logging import sys from abc import ABC, abstractmethod +from typing import Optional from inventory_management_system_api.core.database import get_database from inventory_management_system_api.migrations.core import ( @@ -23,17 +24,30 @@ database = get_database() -def check_user_sure() -> bool: +def check_user_sure(message: Optional[str] = None, skip: bool = False) -> bool: """ Asks user if they are sure action should proceed and exits if not. + :param message: Message to accompany the check. + :param skip: Whether to skip printing out the message and performing the check. :return: Whether user is sure. """ + if skip: + return True + + print(message) + print() answer = input("Are you sure you wish to proceed? ") return answer in ("y", "yes") +def add_skip_args(parser: argparse.ArgumentParser): + """Adds common arguments for skipping user prompts.""" + + parser.add_argument("--yes", "-y", help="Specify to skip all are you sure prompts", action="store_true") + + class SubCommand(ABC): """Base class for a sub command.""" @@ -198,6 +212,8 @@ def __init__(self): def setup(self, parser: argparse.ArgumentParser): parser.add_argument("name", help="Name of the last migration the database currently matches.") + add_skip_args(parser) + def run(self, args: argparse.Namespace): available_migrations = find_available_migrations() @@ -206,10 +222,10 @@ def run(self, args: argparse.Namespace): except ValueError: sys.exit(f"Migration '{args.name}' was not found in the available list of migrations") - print(f"This operation will forcibly set the latest migration to '{available_migrations[end_index]}'") - print() - - if check_user_sure(): + if check_user_sure( + message=f"This operation will forcibly set the latest migration to '{available_migrations[end_index]}'", + skip=args.yes, + ): set_previous_migration(available_migrations[end_index]) diff --git a/scripts/dev_cli.py b/scripts/dev_cli.py index 5d803fe9..11d95021 100644 --- a/scripts/dev_cli.py +++ b/scripts/dev_cli.py @@ -234,6 +234,10 @@ def run(self, args: argparse.Namespace): generate_mock_data() except ImportError: logging.error("Failed to find generate_mock_data.py") + + logging.info("Ensuring previous migration is set to latest...") + run_command(["ims-migrate", "set", "latest", "-y"]) + if args.dump: logging.info("Dumping output...") # Dump output again From 8063e391bfcbf21224b115a3732e19387775b87a Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Tue, 26 Nov 2024 15:38:20 +0000 Subject: [PATCH 05/11] Update existing migration scripts to match template #425 --- data/mock_data.dump | Bin 179050 -> 179062 bytes .../migrations/script.py | 13 +++----- ...41016101400_expected_lifetime_migration.py | 15 +++------ ...241125102300_number_of_spares_migration.py | 15 +++------ .../scripts/20241126124931_test_migration.py | 31 ------------------ 5 files changed, 15 insertions(+), 59 deletions(-) delete mode 100644 inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py diff --git a/data/mock_data.dump b/data/mock_data.dump index e699dc9efb43d1d96906ae25fe0f64497dbc24a4..4a2e782d30afd4e491a62750c29699afaa9086b6 100644 GIT binary patch literal 179062 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 T1&u$p&m?Wdq8*dl=+F9p4yfp~ literal 179050 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∨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 M_{6C%^@z{ bool: if skip: return True - print(message) - print() + if message: + print(message) + print() answer = input("Are you sure you wish to proceed? ") return answer in ("y", "yes") @@ -79,27 +80,23 @@ def run(self, args: argparse.Namespace): with open(f"inventory_management_system_api/migrations/scripts/{file_name}", "w", encoding="utf-8") as file: file.write( f'''""" -Module providing a migration that {args.description} +Module providing a migration that {args.description}. """ # Expect some duplicate code inside migrations as models can be duplicated # pylint: disable=invalid-name # pylint: disable=duplicate-code -import logging - from pymongo.client_session import ClientSession from pymongo.database import Database from inventory_management_system_api.migrations.base import BaseMigration -logger = logging.getLogger() - class Migration(BaseMigration): """Migration that {args.description}""" - description = "{args.description}" + description = "{args.description.capitalize()}" def __init__(self, database: Database): pass diff --git a/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py index 1e4d1e16..4a82c501 100644 --- a/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241016101400_expected_lifetime_migration.py @@ -1,12 +1,11 @@ """ -Module providing a migration for the optional expected_lifetime_days field under catalogue items +Module providing a migration that adds expected_lifetime_days to catalogue items. """ # Expect some duplicate code inside migrations as models can be duplicated # pylint: disable=invalid-name # pylint: disable=duplicate-code -import logging from typing import Any, Collection, List, Optional from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_serializer, field_validator @@ -18,8 +17,6 @@ from inventory_management_system_api.models.custom_object_id_data_types import CustomObjectIdField, StringObjectIdField from inventory_management_system_api.models.mixins import CreatedModifiedTimeInMixin, CreatedModifiedTimeOutMixin -logger = logging.getLogger() - class NewCatalogueItemBase(BaseModel): """ @@ -137,19 +134,18 @@ class OldCatalogueItemOut(CreatedModifiedTimeOutMixin, OldCatalogueItemBase): class Migration(BaseMigration): - """Migration for Catalogue Items' Optional Expected Lifetime Days Field""" + """Migration that adds expected_lifetime_days to catalogue items.""" - description = "Migration for Catalogue Items' Optional Expected Lifetime Days Field" + description = "Adds expected_lifetime_days to catalogue items" def __init__(self, database: Database): self._catalogue_items_collection: Collection = database.catalogue_items def forward(self, session: ClientSession): - """Forward Migration for Catalogue Items' Optional Expected Lifetime Days Field""" + """Applies database changes.""" catalogue_items = self._catalogue_items_collection.find({}, session=session) - logger.info("expected_lifetime_days forward migration") for catalogue_item in catalogue_items: old_catalogue_item = OldCatalogueItemOut(**catalogue_item) new_catalogue_item = NewCatalogueItemIn(**old_catalogue_item.model_dump()) @@ -162,9 +158,8 @@ def forward(self, session: ClientSession): self._catalogue_items_collection.replace_one({"_id": catalogue_item["_id"]}, update_data, session=session) def backward(self, session: ClientSession): - """Backward Migration for Catalogue Items' Optional Expected Lifetime Days Field""" + """Reverses database changes.""" - logger.info("expected_lifetime_days backward migration") result = self._catalogue_items_collection.update_many( {}, {"$unset": {"expected_lifetime_days": ""}}, session=session ) diff --git a/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py index da0121d0..9d9e8336 100644 --- a/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py +++ b/inventory_management_system_api/migrations/scripts/20241125102300_number_of_spares_migration.py @@ -1,12 +1,11 @@ """ -Module providing an example migration that does nothing +Module providing a migration that adds number_of_spares to catalogue items. """ # Expect some duplicate code inside migrations as models can be duplicated # pylint: disable=invalid-name # pylint: disable=duplicate-code -import logging from typing import Any, Collection, List, Optional from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_serializer, field_validator @@ -18,8 +17,6 @@ from inventory_management_system_api.models.custom_object_id_data_types import CustomObjectIdField, StringObjectIdField from inventory_management_system_api.models.mixins import CreatedModifiedTimeInMixin, CreatedModifiedTimeOutMixin -logger = logging.getLogger() - class NewCatalogueItemBase(BaseModel): """ @@ -141,19 +138,18 @@ class OldCatalogueItemOut(CreatedModifiedTimeOutMixin, OldCatalogueItemBase): class Migration(BaseMigration): - """Migration for Catalogue Items' Number of Spares Field""" + """Migration that adds number_of_spares to catalogue items.""" - description = "Migration for Catalogue Items' Number of Spares Field" + description = "Adds number_of_spares to catalogue items" def __init__(self, database: Database): self._catalogue_items_collection: Collection = database.catalogue_items def forward(self, session: ClientSession): - """Forward Migration for Catalogue Items' Number of Spares Field""" + """Applies database changes.""" catalogue_items = self._catalogue_items_collection.find({}, session=session) - logger.info("number_of_spares forward migration") for catalogue_item in catalogue_items: old_catalogue_item = OldCatalogueItemOut(**catalogue_item) new_catalogue_item = NewCatalogueItemIn(**old_catalogue_item.model_dump()) @@ -166,8 +162,7 @@ def forward(self, session: ClientSession): self._catalogue_items_collection.replace_one({"_id": catalogue_item["_id"]}, update_data, session=session) def backward(self, session: ClientSession): - """Backward Migration for Catalogue Items' Number of Spares Field""" + """Reverses database changes.""" - logger.info("number_of_spares backward migration") result = self._catalogue_items_collection.update_many({}, {"$unset": {"number_of_spares": ""}}, session=session) return result diff --git a/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py b/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py deleted file mode 100644 index 3c12bf87..00000000 --- a/inventory_management_system_api/migrations/scripts/20241126124931_test_migration.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Module providing a migration that Does nothing -""" - -# Expect some duplicate code inside migrations as models can be duplicated -# pylint: disable=invalid-name -# pylint: disable=duplicate-code - -import logging - -from pymongo.client_session import ClientSession -from pymongo.database import Database - -from inventory_management_system_api.migrations.base import BaseMigration - -logger = logging.getLogger() - - -class Migration(BaseMigration): - """Migration that Does nothing""" - - description = "Does nothing" - - def __init__(self, database: Database): - pass - - def forward(self, session: ClientSession): - """Applies database changes.""" - - def backward(self, session: ClientSession): - """Reverses database changes.""" From 748876930958f2b63e8e795324b081d5aee4e292 Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Tue, 26 Nov 2024 16:50:03 +0000 Subject: [PATCH 06/11] Add some tests for forward migrations #425 --- test/unit/migrations/test_core.py | 154 ++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 test/unit/migrations/test_core.py diff --git a/test/unit/migrations/test_core.py b/test/unit/migrations/test_core.py new file mode 100644 index 00000000..8cdcd63f --- /dev/null +++ b/test/unit/migrations/test_core.py @@ -0,0 +1,154 @@ +""" +Unit tests for functions inside the `core` module. +""" + +from typing import Optional +from unittest.mock import MagicMock, call, patch + +import pytest + +from inventory_management_system_api.migrations.core import load_forward_migrations_to + +AVAILABLE_MIGRATIONS = ["migration1", "migration2", "migration3"] + + +class MigrationsDSL: + """Base class for migration tests.""" + + _mock_load_migration: MagicMock + _mock_find_available_migrations: MagicMock + _mock_get_previous_migration: MagicMock + + _available_migrations: list[str] + _obtained_forward_migrations: dict[str, MagicMock] + _load_forward_migrations_to_error: pytest.ExceptionInfo + + @pytest.fixture(autouse=True) + def setup(self): + """Setup fixtures.""" + + with patch("inventory_management_system_api.migrations.core.load_migration") as mock_load_migration: + self._mock_load_migration = mock_load_migration + with patch( + "inventory_management_system_api.migrations.core.find_available_migrations" + ) as mock_find_available_migrations: + self._mock_find_available_migrations = mock_find_available_migrations + with patch( + "inventory_management_system_api.migrations.core.get_previous_migration" + ) as mock_get_previous_migration: + self._mock_get_previous_migration = mock_get_previous_migration + yield + + def mock_load_forward_migrations_to( + self, available_migrations: list[str], previous_migration: Optional[str] + ) -> None: + """ + Mocks appropriate methods to test the `load_forward_migrations_to` method. + + :param available_migrations: List of available migrations. + :param previous_migration: Previous migration stored in the database. + """ + + self._available_migrations = available_migrations + + self._mock_find_available_migrations.return_value = self._available_migrations + self._mock_get_previous_migration.return_value = previous_migration + + def call_load_forward_migrations_to(self, name: str) -> None: + """ + Calls the `load_forward_migrations_to` method. + + :param name: Name of the last forward migration to apply. + """ + + self._obtained_forward_migrations = load_forward_migrations_to(name) + + def call_load_forward_migrations_to_expecting_error(self, name: str, error_type: type[BaseException]) -> None: + """ + Calls the `load_forward_migrations_to` method while expecting an error to be raised. + + :param name: Name of the last forward migration to apply. + :param error_type: Expected exception to be raised. + """ + + with pytest.raises(error_type) as exc: + load_forward_migrations_to(name) + self._load_forward_migrations_to_error = exc + + def check_load_forward_migrations_to_success(self, expected_migration_names: list[str]) -> None: + """ + Checks that a prior call to `load_forward_migrations_to` worked as expected. + + :param expected_migration_names: Names of the expected returned migrations to perform. + """ + + self._mock_load_migration.assert_has_calls( + [call(migration_name) for migration_name in expected_migration_names] + ) + assert self._obtained_forward_migrations == { + migration_name: self._mock_load_migration.return_value for migration_name in expected_migration_names + } + + def check_load_forward_migrations_to_success_failed_with_exception(self, message: str) -> None: + """ + Checks that a prior call to `call_load_forward_migrations_to_expecting_error` worked as expected, raising an + exception with the correct message. + + :param message: Message of the raised exception. + """ + + self._mock_load_migration.assert_not_called() + + assert str(self._load_forward_migrations_to_error.value) == message + + +class TestMigrations(MigrationsDSL): + """Tests for performing migrations.""" + + def test_forward_migrations_to_latest_from_none(self): + """Tests `forward_migrations_to` when going to the latest migration with no previous migrations.""" + + self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_forward_migrations_to("latest") + self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS) + + def test_forward_migrations_to_latest_from_another(self): + """Tests `forward_migrations_to` when going to the latest migration with a previous migration.""" + + self.mock_load_forward_migrations_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[0] + ) + self.call_load_forward_migrations_to("latest") + self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS[1:]) + + def test_forward_migrations_to_specific_from_none(self): + """Tests `forward_migrations_to` when going to a specific migration with no previous migrations.""" + + self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_forward_migrations_to(AVAILABLE_MIGRATIONS[1]) + self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS[0:2]) + + def test_forward_migrations_to_specific_from_another(self): + """Tests `forward_migrations_to` when going to a specific migration with a previous migration.""" + + self.mock_load_forward_migrations_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[1] + ) + self.call_load_forward_migrations_to(AVAILABLE_MIGRATIONS[2]) + self.check_load_forward_migrations_to_success([AVAILABLE_MIGRATIONS[2]]) + + def test_forward_migrations_to_from_unknown(self): + """Tests `forward_migrations_to` when to a migration from an unknown one.""" + + self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration="unknown") + self.call_load_forward_migrations_to("latest") + self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS) + + def test_forward_migrations_to_invalid(self): + """Tests `forward_migrations_to` when going to an invalid migration.""" + + self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_forward_migrations_to_expecting_error("invalid", SystemExit) + self.check_load_forward_migrations_to_success_failed_with_exception( + "Migration 'invalid' was not found in the available list of migrations" + ) From 230a2397cc4d1164f8981522e69824ff6984111f Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Wed, 27 Nov 2024 10:04:51 +0000 Subject: [PATCH 07/11] Add some tests for backward migrations #425 --- .../migrations/core.py | 28 +- .../migrations/script.py | 16 +- test/unit/migrations/test_core.py | 272 ++++++++++++++---- 3 files changed, 240 insertions(+), 76 deletions(-) diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py index f356e774..e6e6b3cb 100644 --- a/inventory_management_system_api/migrations/core.py +++ b/inventory_management_system_api/migrations/core.py @@ -84,12 +84,12 @@ def find_migration_index(name: str, migration_names: list[str]) -> int: return migration_names.index(name) -def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: +def load_migrations_forward_to(name: str) -> dict[str, BaseMigration]: """ - Returns a list of forward migrations that need to be applied to get from the last migration applied to the database + Returns a list of migrations forward that need to be applied to get from the last migration applied to the database to the given one inclusive. - :param name: Name of the last forward migration to apply. 'latest' will just use the latest one. + :param name: Name of the last migration forward to apply. 'latest' will just use the latest one. :returns: List of dicts containing the names and instances of the migrations that need to be applied in the order they should be applied. """ @@ -111,7 +111,7 @@ def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: try: end_index = find_migration_index(name, available_migrations) except ValueError: - sys.exit(f"Migration '{name}' was not found in the available list of migrations") + sys.exit(f"Migration '{name}' was not found in the available list of migrations.") if start_index > end_index: sys.exit( @@ -123,12 +123,12 @@ def load_forward_migrations_to(name: str) -> dict[str, BaseMigration]: return {name: load_migration(name) for name in available_migrations[start_index : end_index + 1]} -def load_backward_migrations_to(name: str) -> tuple[dict[str, BaseMigration], Optional[str]]: +def load_migrations_backward_to(name: str) -> tuple[dict[str, BaseMigration], Optional[str]]: """ - Returns a list of backward migrations that need to be applied to get from the last migration applied to the database + Returns a list of migrations backward that need to be applied to get from the last migration applied to the database to the given one inclusive. - :param name: Name of the last backward migration to apply. + :param name: Name of the last migration backward to apply. :returns: Tuple containing: - List of dicts containing the names and instances of the migrations that need to be applied in the order they should be applied. @@ -154,12 +154,12 @@ def load_backward_migrations_to(name: str) -> tuple[dict[str, BaseMigration], Op try: end_index = find_migration_index(name, available_migrations) - 1 except ValueError: - sys.exit(f"Migration '{name}' was not found in the available list of migrations") + sys.exit(f"Migration '{name}' was not found in the available list of migrations.") if start_index <= end_index: sys.exit( - f"Migration '{name}' is already reverted or after the previous migration applied '{previous_migration}. " - "So there is nothing to migrate.'" + f"Migration '{name}' is already reverted or after the previous migration applied '{previous_migration}'. " + "So there is nothing to migrate." ) final_previous_migration_name = available_migrations[end_index] if end_index >= 0 else None @@ -174,9 +174,9 @@ def load_backward_migrations_to(name: str) -> tuple[dict[str, BaseMigration], Op }, final_previous_migration_name -def execute_forward_migrations(migrations: dict[str, BaseMigration]) -> None: +def execute_migrations_forward(migrations: dict[str, BaseMigration]) -> None: """ - Executes a list of forward migrations in order. + Executes a list of migrations forward in order. All `forward_after_transaction`'s are executed AFTER the all of the `forward`'s are executed. This is so that the latter can be done at once in a transaction. @@ -198,9 +198,9 @@ def execute_forward_migrations(migrations: dict[str, BaseMigration]) -> None: migration.forward_after_transaction(session) -def execute_backward_migrations(migrations: dict[str, BaseMigration], final_previous_migration_name: Optional[str]): +def execute_migrations_backward(migrations: dict[str, BaseMigration], final_previous_migration_name: Optional[str]): """ - Executes a list of backward migrations in order. + Executes a list of migrations backward in order. All `backward_after_transaction`'s are executed AFTER the all of the `backward`'s are executed. This is so that the latter can be done at once in a transaction. diff --git a/inventory_management_system_api/migrations/script.py b/inventory_management_system_api/migrations/script.py index 5a8d0d9f..8c4925b2 100644 --- a/inventory_management_system_api/migrations/script.py +++ b/inventory_management_system_api/migrations/script.py @@ -9,14 +9,14 @@ from inventory_management_system_api.core.database import get_database from inventory_management_system_api.migrations.core import ( - execute_backward_migrations, - execute_forward_migrations, + execute_migrations_backward, + execute_migrations_forward, find_available_migrations, find_migration_index, get_previous_migration, - load_backward_migrations_to, - load_forward_migrations_to, load_migration, + load_migrations_backward_to, + load_migrations_forward_to, set_previous_migration, ) @@ -166,7 +166,7 @@ def setup(self, parser: argparse.ArgumentParser): ) def run(self, args: argparse.Namespace): - migrations = load_forward_migrations_to(args.name) + migrations = load_migrations_forward_to(args.name) print("This operation will apply the following migrations:") for name in migrations.keys(): @@ -174,7 +174,7 @@ def run(self, args: argparse.Namespace): print() if check_user_sure(): - execute_forward_migrations(migrations) + execute_migrations_forward(migrations) logger.info("Done!") @@ -188,7 +188,7 @@ def setup(self, parser: argparse.ArgumentParser): parser.add_argument("name", help="Name migration to migrate backwards to (inclusive).") def run(self, args: argparse.Namespace): - migrations, final_previous_migration_name = load_backward_migrations_to(args.name) + migrations, final_previous_migration_name = load_migrations_backward_to(args.name) print("This operation will revert the following migrations:") for name in migrations.keys(): @@ -196,7 +196,7 @@ def run(self, args: argparse.Namespace): print() if check_user_sure(): - execute_backward_migrations(migrations, final_previous_migration_name) + execute_migrations_backward(migrations, final_previous_migration_name) logger.info("Done!") diff --git a/test/unit/migrations/test_core.py b/test/unit/migrations/test_core.py index 8cdcd63f..00d41bdb 100644 --- a/test/unit/migrations/test_core.py +++ b/test/unit/migrations/test_core.py @@ -7,22 +7,18 @@ import pytest -from inventory_management_system_api.migrations.core import load_forward_migrations_to +from inventory_management_system_api.migrations.core import load_migrations_backward_to, load_migrations_forward_to AVAILABLE_MIGRATIONS = ["migration1", "migration2", "migration3"] -class MigrationsDSL: +class BaseMigrationDSL: """Base class for migration tests.""" _mock_load_migration: MagicMock _mock_find_available_migrations: MagicMock _mock_get_previous_migration: MagicMock - _available_migrations: list[str] - _obtained_forward_migrations: dict[str, MagicMock] - _load_forward_migrations_to_error: pytest.ExceptionInfo - @pytest.fixture(autouse=True) def setup(self): """Setup fixtures.""" @@ -39,11 +35,19 @@ def setup(self): self._mock_get_previous_migration = mock_get_previous_migration yield - def mock_load_forward_migrations_to( + +class LoadMigrationsForwardToDSL(BaseMigrationDSL): + """Base class for `load_migrations_forward_to` tests.""" + + _available_migrations: list[str] + _obtained_migrations_forward: dict[str, MagicMock] + _load_migrations_forward_to_error: pytest.ExceptionInfo + + def mock_load_migrations_forward_to( self, available_migrations: list[str], previous_migration: Optional[str] ) -> None: """ - Mocks appropriate methods to test the `load_forward_migrations_to` method. + Mocks appropriate methods to test the `load_migrations_forward_to` method. :param available_migrations: List of available migrations. :param previous_migration: Previous migration stored in the database. @@ -54,30 +58,30 @@ def mock_load_forward_migrations_to( self._mock_find_available_migrations.return_value = self._available_migrations self._mock_get_previous_migration.return_value = previous_migration - def call_load_forward_migrations_to(self, name: str) -> None: + def call_load_migrations_forward_to(self, name: str) -> None: """ - Calls the `load_forward_migrations_to` method. + Calls the `load_migrations_forward_to` method. :param name: Name of the last forward migration to apply. """ - self._obtained_forward_migrations = load_forward_migrations_to(name) + self._obtained_migrations_forward = load_migrations_forward_to(name) - def call_load_forward_migrations_to_expecting_error(self, name: str, error_type: type[BaseException]) -> None: + def call_load_migrations_forward_to_expecting_error(self, name: str, error_type: type[BaseException]) -> None: """ - Calls the `load_forward_migrations_to` method while expecting an error to be raised. + Calls the `load_migrations_forward_to` method while expecting an error to be raised. :param name: Name of the last forward migration to apply. :param error_type: Expected exception to be raised. """ with pytest.raises(error_type) as exc: - load_forward_migrations_to(name) - self._load_forward_migrations_to_error = exc + load_migrations_forward_to(name) + self._load_migrations_forward_to_error = exc - def check_load_forward_migrations_to_success(self, expected_migration_names: list[str]) -> None: + def check_load_migrations_forward_to_success(self, expected_migration_names: list[str]) -> None: """ - Checks that a prior call to `load_forward_migrations_to` worked as expected. + Checks that a prior call to `load_migrations_forward_to` worked as expected. :param expected_migration_names: Names of the expected returned migrations to perform. """ @@ -85,13 +89,13 @@ def check_load_forward_migrations_to_success(self, expected_migration_names: lis self._mock_load_migration.assert_has_calls( [call(migration_name) for migration_name in expected_migration_names] ) - assert self._obtained_forward_migrations == { + assert self._obtained_migrations_forward == { migration_name: self._mock_load_migration.return_value for migration_name in expected_migration_names } - def check_load_forward_migrations_to_success_failed_with_exception(self, message: str) -> None: + def check_load_migrations_forward_to_success_failed_with_exception(self, message: str) -> None: """ - Checks that a prior call to `call_load_forward_migrations_to_expecting_error` worked as expected, raising an + Checks that a prior call to `call_load_migrations_forward_to_expecting_error` worked as expected, raising an exception with the correct message. :param message: Message of the raised exception. @@ -99,56 +103,216 @@ def check_load_forward_migrations_to_success_failed_with_exception(self, message self._mock_load_migration.assert_not_called() - assert str(self._load_forward_migrations_to_error.value) == message + assert str(self._load_migrations_forward_to_error.value) == message -class TestMigrations(MigrationsDSL): - """Tests for performing migrations.""" +class TestLoadMigrationsForwardToDSL(LoadMigrationsForwardToDSL): + """Tests for loading migrations forward to migrate to a point.""" - def test_forward_migrations_to_latest_from_none(self): - """Tests `forward_migrations_to` when going to the latest migration with no previous migrations.""" + def test_load_migrations_forward_to_latest_from_none(self): + """Tests loading migrations forward to the latest migration from no previous migrations.""" - self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) - self.call_load_forward_migrations_to("latest") - self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS) + self.mock_load_migrations_forward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_migrations_forward_to("latest") + self.check_load_migrations_forward_to_success(AVAILABLE_MIGRATIONS) - def test_forward_migrations_to_latest_from_another(self): - """Tests `forward_migrations_to` when going to the latest migration with a previous migration.""" + def test_load_migrations_forward_to_latest_from_another(self): + """Tests loading migrations forward to the latest migration from a previous migration.""" - self.mock_load_forward_migrations_to( + self.mock_load_migrations_forward_to( available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[0] ) - self.call_load_forward_migrations_to("latest") - self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS[1:]) + self.call_load_migrations_forward_to("latest") + self.check_load_migrations_forward_to_success(AVAILABLE_MIGRATIONS[1:]) + + def test_load_migrations_forward_to_specific_from_none(self): + """Tests loading migrations forward to a specific migration from no previous migrations.""" + + self.mock_load_migrations_forward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_migrations_forward_to(AVAILABLE_MIGRATIONS[1]) + self.check_load_migrations_forward_to_success(AVAILABLE_MIGRATIONS[0:2]) + + def test_load_migrations_forward_to_specific_from_another(self): + """Tests loading migrations forward to a specific migration from a previous migration.""" + + self.mock_load_migrations_forward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[1] + ) + self.call_load_migrations_forward_to(AVAILABLE_MIGRATIONS[2]) + self.check_load_migrations_forward_to_success([AVAILABLE_MIGRATIONS[2]]) + + def test_load_migrations_forward_to_from_unknown(self): + """Tests loading migrations forward to a migration from a previous unknown one.""" + + self.mock_load_migrations_forward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration="unknown") + self.call_load_migrations_forward_to("latest") + self.check_load_migrations_forward_to_success(AVAILABLE_MIGRATIONS) + + def test_load_migrations_forward_to_invalid(self): + """Tests loading migrations forward to an invalid migration.""" + + self.mock_load_migrations_forward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_migrations_forward_to_expecting_error("invalid", SystemExit) + self.check_load_migrations_forward_to_success_failed_with_exception( + "Migration 'invalid' was not found in the available list of migrations." + ) + + def test_load_migrations_forward_to_older(self): + """Tests loading migrations forward to an older migration.""" + + self.mock_load_migrations_forward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] + ) + self.call_load_migrations_forward_to_expecting_error(AVAILABLE_MIGRATIONS[1], SystemExit) + self.check_load_migrations_forward_to_success_failed_with_exception( + f"Migration '{AVAILABLE_MIGRATIONS[1]}' is before the previous migration applied " + f"'{AVAILABLE_MIGRATIONS[-1]}'. So there is nothing to migrate." + ) + + +class LoadMigrationsBackwardToDSL(BaseMigrationDSL): + """Base class for `load_migrations_backward_to` tests.""" + + _available_migrations: list[str] + _obtained_migrations_backward: dict[str, MagicMock] + _load_migrations_backward_to_error: pytest.ExceptionInfo + + def mock_load_migrations_backward_to( + self, available_migrations: list[str], previous_migration: Optional[str] + ) -> None: + """ + Mocks appropriate methods to test the `load_migrations_backward_to` method. + + :param available_migrations: List of available migrations. + :param previous_migration: Previous migration stored in the database. + """ + + self._available_migrations = available_migrations + + self._mock_find_available_migrations.return_value = self._available_migrations + self._mock_get_previous_migration.return_value = previous_migration + + def call_load_migrations_backward_to(self, name: str) -> None: + """ + Calls the `load_migrations_backward_to` method. + + :param name: Name of the last backward migration to apply. + """ + + self._obtained_migrations_backward = load_migrations_backward_to(name) + + def call_load_migrations_backward_to_expecting_error(self, name: str, error_type: type[BaseException]) -> None: + """ + Calls the `load_migrations_backward_to` method while expecting an error to be raised. + + :param name: Name of the last backward migration to apply. + :param error_type: Expected exception to be raised. + """ + + with pytest.raises(error_type) as exc: + load_migrations_backward_to(name) + self._load_migrations_backward_to_error = exc + + def check_load_migrations_backward_to_success( + self, expected_migration_names: list[str], expected_final_previous_migration_name: Optional[str] + ) -> None: + """ + Checks that a prior call to `load_migrations_backward_to` worked as expected. + + :param expected_migration_names: Names of the expected returned migrations to perform. + :param expected_final_previous_migration_name: Expected final previous migration name returned. + """ + + print(expected_migration_names) + print(self._obtained_migrations_backward) + self._mock_load_migration.assert_has_calls( + [call(migration_name) for migration_name in expected_migration_names] + ) + assert self._obtained_migrations_backward == ( + {migration_name: self._mock_load_migration.return_value for migration_name in expected_migration_names}, + expected_final_previous_migration_name, + ) - def test_forward_migrations_to_specific_from_none(self): - """Tests `forward_migrations_to` when going to a specific migration with no previous migrations.""" + def check_load_migrations_backward_to_success_failed_with_exception(self, message: str) -> None: + """ + Checks that a prior call to `call_load_migrations_backward_to_expecting_error` worked as expected, raising an + exception with the correct message. - self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) - self.call_load_forward_migrations_to(AVAILABLE_MIGRATIONS[1]) - self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS[0:2]) + :param message: Message of the raised exception. + """ + + self._mock_load_migration.assert_not_called() + + assert str(self._load_migrations_backward_to_error.value) == message - def test_forward_migrations_to_specific_from_another(self): - """Tests `forward_migrations_to` when going to a specific migration with a previous migration.""" - self.mock_load_forward_migrations_to( +class TestLoadMigrationsBackwardToDSL(LoadMigrationsBackwardToDSL): + """Tests for loading migrations backward to migrate to a point.""" + + def test_load_migrations_backward_to_oldest_from_latest(self): + """Tests loading migrations backward to the oldest available migration from the latest one.""" + + self.mock_load_migrations_backward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] + ) + self.call_load_migrations_backward_to(AVAILABLE_MIGRATIONS[0]) + self.check_load_migrations_backward_to_success(AVAILABLE_MIGRATIONS[::-1], None) + + def test_load_migrations_backward_to_oldest_from_another(self): + """Tests loading migrations backward to the oldest available migration from a previous migration.""" + + self.mock_load_migrations_backward_to( available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[1] ) - self.call_load_forward_migrations_to(AVAILABLE_MIGRATIONS[2]) - self.check_load_forward_migrations_to_success([AVAILABLE_MIGRATIONS[2]]) + self.call_load_migrations_backward_to(AVAILABLE_MIGRATIONS[0]) + self.check_load_migrations_backward_to_success(AVAILABLE_MIGRATIONS[1::-1], None) - def test_forward_migrations_to_from_unknown(self): - """Tests `forward_migrations_to` when to a migration from an unknown one.""" + def test_load_migrations_backward_to_specific_from_latest(self): + """Tests loading migrations backward to a specific migration from the latest one.""" - self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration="unknown") - self.call_load_forward_migrations_to("latest") - self.check_load_forward_migrations_to_success(AVAILABLE_MIGRATIONS) + self.mock_load_migrations_backward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] + ) + self.call_load_migrations_backward_to(AVAILABLE_MIGRATIONS[1]) + self.check_load_migrations_backward_to_success(AVAILABLE_MIGRATIONS[-1:0:-1], AVAILABLE_MIGRATIONS[0]) - def test_forward_migrations_to_invalid(self): - """Tests `forward_migrations_to` when going to an invalid migration.""" + def test_load_migrations_backward_to_specific_from_another(self): + """Tests loading migrations backward to a specific migration from the latest one.""" + + self.mock_load_migrations_backward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] + ) + self.call_load_migrations_backward_to(AVAILABLE_MIGRATIONS[1]) + self.check_load_migrations_backward_to_success(AVAILABLE_MIGRATIONS[-1:0:-1], AVAILABLE_MIGRATIONS[0]) - self.mock_load_forward_migrations_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) - self.call_load_forward_migrations_to_expecting_error("invalid", SystemExit) - self.check_load_forward_migrations_to_success_failed_with_exception( - "Migration 'invalid' was not found in the available list of migrations" + def test_load_migrations_backward_to_from_unknown(self): + """Tests loading migrations backward to a migration from a previous unknown one.""" + + self.mock_load_migrations_backward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration="unknown") + self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[0], SystemExit) + self.check_load_migrations_backward_to_success_failed_with_exception( + "Previous migration applied 'unknown' not found in current migrations. Have you skipped a version?" + ) + + def test_load_migrations_backward_to_invalid(self): + """Tests loading migrations backward to an invalid migration.""" + + self.mock_load_migrations_backward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] + ) + self.call_load_migrations_backward_to_expecting_error("invalid", SystemExit) + self.check_load_migrations_backward_to_success_failed_with_exception( + "Migration 'invalid' was not found in the available list of migrations." + ) + + def test_load_migrations_backward_to_newer(self): + """Tests loading migrations backward to an newer migration.""" + + self.mock_load_migrations_backward_to( + available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[0] + ) + self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[-1], SystemExit) + self.check_load_migrations_backward_to_success_failed_with_exception( + f"Migration '{AVAILABLE_MIGRATIONS[-1]}' is already reverted or after the previous migration applied " + f"'{AVAILABLE_MIGRATIONS[0]}'. So there is nothing to migrate." ) From cf0edc5e9fd738d5aac9fa21e50e457f40a50d93 Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Wed, 27 Nov 2024 10:53:01 +0000 Subject: [PATCH 08/11] Improve code coverage by adding more unit tests #425 --- test/unit/migrations/test_core.py | 112 +++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/test/unit/migrations/test_core.py b/test/unit/migrations/test_core.py index 00d41bdb..7b3f1187 100644 --- a/test/unit/migrations/test_core.py +++ b/test/unit/migrations/test_core.py @@ -7,7 +7,16 @@ import pytest -from inventory_management_system_api.migrations.core import load_migrations_backward_to, load_migrations_forward_to +from inventory_management_system_api.migrations.core import ( + execute_migrations_backward, + execute_migrations_forward, + find_available_migrations, + get_previous_migration, + load_migration, + load_migrations_backward_to, + load_migrations_forward_to, + set_previous_migration, +) AVAILABLE_MIGRATIONS = ["migration1", "migration2", "migration3"] @@ -223,8 +232,6 @@ def check_load_migrations_backward_to_success( :param expected_final_previous_migration_name: Expected final previous migration name returned. """ - print(expected_migration_names) - print(self._obtained_migrations_backward) self._mock_load_migration.assert_has_calls( [call(migration_name) for migration_name in expected_migration_names] ) @@ -285,6 +292,13 @@ def test_load_migrations_backward_to_specific_from_another(self): self.call_load_migrations_backward_to(AVAILABLE_MIGRATIONS[1]) self.check_load_migrations_backward_to_success(AVAILABLE_MIGRATIONS[-1:0:-1], AVAILABLE_MIGRATIONS[0]) + def test_load_migrations_backward_to_specific_from_none(self): + """Tests loading migrations backward to a specific migration from no previous migrations.""" + + self.mock_load_migrations_backward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) + self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[1], SystemExit) + self.check_load_migrations_backward_to_success_failed_with_exception("No migrations to revert.") + def test_load_migrations_backward_to_from_unknown(self): """Tests loading migrations backward to a migration from a previous unknown one.""" @@ -316,3 +330,95 @@ def test_load_migrations_backward_to_newer(self): f"Migration '{AVAILABLE_MIGRATIONS[-1]}' is already reverted or after the previous migration applied " f"'{AVAILABLE_MIGRATIONS[0]}'. So there is nothing to migrate." ) + + +# The following are some basic tests that did not warrant their own classes +def test_load_migration(): + """Tests that `load_migration` functions without erroring.""" + + load_migration("_example_migration") + + +def test_load_migration_non_existent(): + """Tests that `load_migration` produces an error if the migration named is non-existent.""" + + with pytest.raises(ModuleNotFoundError): + load_migration("_example_migration2") + + +def test_find_available_migrations(): + """Tests that `find_available_migrations` functions without erroring.""" + + assert isinstance(find_available_migrations(), list) + + +@patch("inventory_management_system_api.migrations.core.database") +def test_get_previous_migration(mock_database): + """Tests that `get_previous_migration` functions as expected when there is a previous migrations.""" + + mock_database.database_migrations.find_one.return_value = {"name": "migration_name"} + + previous_migration = get_previous_migration() + + mock_database.database_migrations.find_one.assert_called_once_with({"_id": "previous_migration"}) + assert previous_migration == "migration_name" + + +@patch("inventory_management_system_api.migrations.core.database") +def test_set_previous_migration(mock_database): + """Tests that `set_previous_migration` functions as expected when there is a previous migrations.""" + + set_previous_migration("migration_name") + + mock_database.database_migrations.update_one.assert_called_once_with( + {"_id": "previous_migration"}, {"$set": {"name": "migration_name"}}, upsert=True + ) + + +@patch("inventory_management_system_api.migrations.core.database") +def test_get_previous_migration_when_none(mock_database): + """Tests that `get_previous_migration` functions as expected when there are no previous migrations.""" + + mock_database.database_migrations.find_one.return_value = None + + previous_migration = get_previous_migration() + + mock_database.database_migrations.find_one.assert_called_with({"_id": "previous_migration"}) + assert previous_migration is None + + +@patch("inventory_management_system_api.migrations.core.set_previous_migration") +@patch("inventory_management_system_api.migrations.core.mongodb_client") +def test_execute_migrations_forward(mock_mongodb_client, mock_set_previous_migration): + """Tests that `execute_migrations_forward` functions as expected.""" + + migrations = {"migration1": MagicMock(), "migration2": MagicMock()} + expected_session = mock_mongodb_client.start_session.return_value.__enter__.return_value + + execute_migrations_forward(migrations) + + expected_session.start_transaction.assert_called_once() + for migration in migrations.values(): + migration.forward.assert_called_once_with(expected_session) + migration.forward_after_transaction.assert_called_once_with(expected_session) + + mock_set_previous_migration.assert_called_once_with(list(migrations.keys())[-1]) + + +@patch("inventory_management_system_api.migrations.core.set_previous_migration") +@patch("inventory_management_system_api.migrations.core.mongodb_client") +def test_execute_migrations_backward(mock_mongodb_client, mock_set_previous_migration): + """Tests that `execute_migrations_backward` functions as expected.""" + + migrations = {"migration1": MagicMock(), "migration2": MagicMock()} + final_previous_migration_name = "final_migration_name" + expected_session = mock_mongodb_client.start_session.return_value.__enter__.return_value + + execute_migrations_backward(migrations, final_previous_migration_name) + + expected_session.start_transaction.assert_called_once() + for migration in migrations.values(): + migration.backward.assert_called_once_with(expected_session) + migration.backward_after_transaction.assert_called_once_with(expected_session) + + mock_set_previous_migration.assert_called_once_with(final_previous_migration_name) From ce70ba1372d1b95abc243af71b6112a8323a042c Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Thu, 5 Dec 2024 11:42:05 +0000 Subject: [PATCH 09/11] Fix typos from review comments #425 --- .../migrations/core.py | 2 +- test/unit/migrations/test_core.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py index e6e6b3cb..04acd0bd 100644 --- a/inventory_management_system_api/migrations/core.py +++ b/inventory_management_system_api/migrations/core.py @@ -58,7 +58,7 @@ def get_previous_migration() -> Optional[str]: def set_previous_migration(name: Optional[str]) -> None: """ - Assigns the name of the of the previous migration that got the database to its current state inside the database. + Assigns the name of the previous migration that got the database to its current state inside the database. :param name: The name of the previous migration applied to the database or `None` if being set back no migrations having been applied. diff --git a/test/unit/migrations/test_core.py b/test/unit/migrations/test_core.py index 7b3f1187..eae4c5c3 100644 --- a/test/unit/migrations/test_core.py +++ b/test/unit/migrations/test_core.py @@ -102,7 +102,7 @@ def check_load_migrations_forward_to_success(self, expected_migration_names: lis migration_name: self._mock_load_migration.return_value for migration_name in expected_migration_names } - def check_load_migrations_forward_to_success_failed_with_exception(self, message: str) -> None: + def check_load_migrations_forward_to_failed_with_exception(self, message: str) -> None: """ Checks that a prior call to `call_load_migrations_forward_to_expecting_error` worked as expected, raising an exception with the correct message. @@ -162,7 +162,7 @@ def test_load_migrations_forward_to_invalid(self): self.mock_load_migrations_forward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) self.call_load_migrations_forward_to_expecting_error("invalid", SystemExit) - self.check_load_migrations_forward_to_success_failed_with_exception( + self.check_load_migrations_forward_to_failed_with_exception( "Migration 'invalid' was not found in the available list of migrations." ) @@ -173,7 +173,7 @@ def test_load_migrations_forward_to_older(self): available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] ) self.call_load_migrations_forward_to_expecting_error(AVAILABLE_MIGRATIONS[1], SystemExit) - self.check_load_migrations_forward_to_success_failed_with_exception( + self.check_load_migrations_forward_to_failed_with_exception( f"Migration '{AVAILABLE_MIGRATIONS[1]}' is before the previous migration applied " f"'{AVAILABLE_MIGRATIONS[-1]}'. So there is nothing to migrate." ) @@ -240,7 +240,7 @@ def check_load_migrations_backward_to_success( expected_final_previous_migration_name, ) - def check_load_migrations_backward_to_success_failed_with_exception(self, message: str) -> None: + def check_load_migrations_backward_to_failed_with_exception(self, message: str) -> None: """ Checks that a prior call to `call_load_migrations_backward_to_expecting_error` worked as expected, raising an exception with the correct message. @@ -297,14 +297,14 @@ def test_load_migrations_backward_to_specific_from_none(self): self.mock_load_migrations_backward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration=None) self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[1], SystemExit) - self.check_load_migrations_backward_to_success_failed_with_exception("No migrations to revert.") + self.check_load_migrations_backward_to_failed_with_exception("No migrations to revert.") def test_load_migrations_backward_to_from_unknown(self): """Tests loading migrations backward to a migration from a previous unknown one.""" self.mock_load_migrations_backward_to(available_migrations=AVAILABLE_MIGRATIONS, previous_migration="unknown") self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[0], SystemExit) - self.check_load_migrations_backward_to_success_failed_with_exception( + self.check_load_migrations_backward_to_failed_with_exception( "Previous migration applied 'unknown' not found in current migrations. Have you skipped a version?" ) @@ -315,7 +315,7 @@ def test_load_migrations_backward_to_invalid(self): available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[-1] ) self.call_load_migrations_backward_to_expecting_error("invalid", SystemExit) - self.check_load_migrations_backward_to_success_failed_with_exception( + self.check_load_migrations_backward_to_failed_with_exception( "Migration 'invalid' was not found in the available list of migrations." ) @@ -326,7 +326,7 @@ def test_load_migrations_backward_to_newer(self): available_migrations=AVAILABLE_MIGRATIONS, previous_migration=AVAILABLE_MIGRATIONS[0] ) self.call_load_migrations_backward_to_expecting_error(AVAILABLE_MIGRATIONS[-1], SystemExit) - self.check_load_migrations_backward_to_success_failed_with_exception( + self.check_load_migrations_backward_to_failed_with_exception( f"Migration '{AVAILABLE_MIGRATIONS[-1]}' is already reverted or after the previous migration applied " f"'{AVAILABLE_MIGRATIONS[0]}'. So there is nothing to migrate." ) From 7b15661e53343c1dcb8561492b8fa8425e207d94 Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Thu, 5 Dec 2024 14:59:41 +0000 Subject: [PATCH 10/11] Address docstring comments #425 --- README.md | 3 ++- inventory_management_system_api/migrations/base.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 221a8529..46e9bd37 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,8 @@ to perform the migration. See `_example_migration.py` for an example on how to i #### Performing forward migrations -Before performing a you can first check the current status of the database and any outstanding migrations using +Before performing a migration you can first check the current status of the database and any outstanding migrations +using ```bash ims-migrate status diff --git a/inventory_management_system_api/migrations/base.py b/inventory_management_system_api/migrations/base.py index 2e6c5bd7..ad8daae3 100644 --- a/inventory_management_system_api/migrations/base.py +++ b/inventory_management_system_api/migrations/base.py @@ -7,7 +7,7 @@ class BaseMigration(ABC): - """Base class for a migration with a forward and backward step""" + """Base class for a migration with a forward and backward step.""" @abstractmethod def __init__(self, database: Database): @@ -16,25 +16,25 @@ def __init__(self, database: Database): @property @abstractmethod def description(self) -> str: - """Description of this migration""" + """Description of this migration.""" return "" @abstractmethod def forward(self, session: ClientSession): - """Method for executing the migration""" + """Method for executing the migration.""" def forward_after_transaction(self, session: ClientSession): """Method called after the forward function is called to do anything that can't be done inside a transaction - (ONLY USE IF NECESSARY e.g. dropping a collection)""" + (ONLY USE IF NECESSARY e.g. dropping a collection).""" @abstractmethod def backward(self, session: ClientSession): - """Method for reversing the migration""" + """Method for reversing the migration.""" def backward_after_transaction(self, session: ClientSession): """ Method called after the backward function is called to do anything that can't be done inside a transaction - (ONLY USE IF NECESSARY e.g. dropping a collection) + (ONLY USE IF NECESSARY e.g. dropping a collection). Note that this can run after other migrations as well so should not interfere with them. """ From b728441ef6089b1115fbb32a468453d0fab46a9c Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Fri, 6 Dec 2024 15:49:39 +0000 Subject: [PATCH 11/11] Address review comments #425 --- README.md | 2 +- inventory_management_system_api/migrations/core.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 46e9bd37..8dfb216f 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ ims-migrate status or in Docker ```bash -docker exec -it inventory_management_system_api_container ims-migrate list +docker exec -it inventory_management_system_api_container ims-migrate status ``` Then to perform all outstanding migrations up to the latest one use diff --git a/inventory_management_system_api/migrations/core.py b/inventory_management_system_api/migrations/core.py index 04acd0bd..1ec12355 100644 --- a/inventory_management_system_api/migrations/core.py +++ b/inventory_management_system_api/migrations/core.py @@ -137,8 +137,6 @@ def load_migrations_backward_to(name: str) -> tuple[dict[str, BaseMigration], Op available_migrations = find_available_migrations() - start_index = 0 - previous_migration = get_previous_migration() if previous_migration is not None: try: