From 7d709059c1f0a9447bf1beecf9972c1037c8c9df Mon Sep 17 00:00:00 2001 From: Kevin Meinhardt Date: Fri, 25 Apr 2025 18:40:57 +0200 Subject: [PATCH] TMP: more of things I don't udnerstand --- .github/workflows/pull_request.yml | 38 ++++ docs/topics/development/database.md | 133 ++++++++++++ docs/topics/development/index.md | 1 + .../migrations/0002_migration_task.py | 20 ++ .../amo/management/commands/run_task.py | 12 ++ src/olympia/core/db/migrations.py | 61 +++++- .../core/management/commands/__init__.py | 88 ++++++++ .../core/management/commands/migrate_task.py | 24 +++ .../management/commands/migrate_waffle.py | 54 +++++ src/olympia/core/tasks.py | 7 + ..._dependencies_from_previous_migrations.txt | 18 ++ .../test_adds_specified_operations.txt | 16 ++ .../test_add_waffle_switch.txt | 16 ++ .../test_delete_waffle_switch.txt | 16 ++ .../test_rename_waffle_switch.txt | 17 ++ src/olympia/core/tests/test_commands.py | 191 ++++++++++++++++++ 16 files changed, 706 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pull_request.yml create mode 100644 docs/topics/development/database.md create mode 100644 src/olympia/accounts/migrations/0002_migration_task.py create mode 100644 src/olympia/amo/management/commands/run_task.py create mode 100644 src/olympia/core/management/commands/__init__.py create mode 100644 src/olympia/core/management/commands/migrate_task.py create mode 100644 src/olympia/core/management/commands/migrate_waffle.py create mode 100644 src/olympia/core/tasks.py create mode 100644 src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_dependencies_from_previous_migrations.txt create mode 100644 src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_specified_operations.txt create mode 100644 src/olympia/core/tests/snapshots/TestMigrateWaffle/test_add_waffle_switch.txt create mode 100644 src/olympia/core/tests/snapshots/TestMigrateWaffle/test_delete_waffle_switch.txt create mode 100644 src/olympia/core/tests/snapshots/TestMigrateWaffle/test_rename_waffle_switch.txt create mode 100644 src/olympia/core/tests/test_commands.py diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 000000000000..252b23abc4ac --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,38 @@ +name: Pull Request + +on: + pull_request: + branches: + - master + +jobs: + check_migrations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Changed files + id: changed_files + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c + with: + since_last_remote_commit: true + files: '**/migrations/*.py' + + - name: Link to migration docs + if: steps.changed_files.outputs.any_changed == 'true' + uses: mozilla/addons/.github/actions/pr-comment@main + env: + docs_base_url: https://mozilla.github.io/addons-server + database_path: /topics/development/database.html + with: + repo: ${{ github.repository }} + github_token: ${{ secrets.GITHUB_TOKEN }} + pr: ${{ github.event.pull_request.number }} + edit_last: true + body: | + ## ⚠️ Data migration detected ⚠️ + + It looks like there are data migrations in your PR. + Please **read our policies** on database migrations before merging. + + [Migration Docs](${{ env.docs_base_url }}${{ env.database_path }}) diff --git a/docs/topics/development/database.md b/docs/topics/development/database.md new file mode 100644 index 000000000000..8a9eb83a8bc7 --- /dev/null +++ b/docs/topics/development/database.md @@ -0,0 +1,133 @@ +# Database + +Information about the database and how to work with it. + +## Migrations + +MySQL does not support transactional DDL (Data Definition Language statements like `ALTER TABLE`, `CREATE TABLE`, etc.). + +Therefore, Django migrations run against MySQL are not truly atomic, even if `atomic = True` is set on the migration class. + +Here's what that means: + +- Implicit Commits: MySQL automatically commits the transaction before and after most DDL statements. +- Partial Application: If a migration involves multiple operations (e.g., adding a field, then creating an index) and fails during the second operation, the first operation (adding the field) will not be rolled back automatically. The database schema will be left in an intermediate state. +- `atomic = True` Limitation: While Django attempts to wrap the migration in a transaction, the underlying database behavior with DDL prevents full atomicity for schema changes in MySQL. DML operations (like `RunPython` or data migrations) within that transaction might be rolled back, but the DDL changes won't be. + +### Writing migrations + +Keep these tips in mind when writing migrations: + +1. Always create a migration via `./manage.py makemigrations` + + This ensures that the migration is created with the correct dependencies, sequential naming etc. (to create an empty migration that executes arbitrary code, use `./manage.py makemigrations --empty `). + +2. If you need to execute arbitrary code, use `RunPython` or `RunSQL` operations. + + These operations are not wrapped in a transaction, so you need to be careful with how you write them and be mindful that the state of the database might not be consistent every time the migration is run. Validate assumptions and be careful. + + If your migration requires `RunPython`, make that the only operation in the migration. Split database table modification to a separate migration to ensure a partial application due to failure does not result in an invalid database state. + +3. Large data migrations should be run via `tasks` + + This ensures they run on an environment that supports long running work. In production (kubernetes) pods are disposable, so you should not assume you can run a long migration in an arbitrary pod. + +### Standard migrations + +Some standard migrations for common changes are covered with custom classes. + +1) Modifying waffle switches + + Create/Delete/Rename a waffle switch can be done with a dedicated migration class. + The class can be generated with a custom management command: + + ```bash + ./manage.py migrate_waffle test_switch --action rename --new_name test_switch_2 + ``` + + ```python + from django.db import migrations + import olympia.core.db.migrations + + + class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.db.migrations.CreateWaffleSwitch( + name='test_switch', + ), + ] + ``` + +2) Back/Forward filling data (RunPython) + + Migrations that modify significant amounts of data should be run via tasks. + You can execute the task via a migration using the custom migration class. + + The task should accept no arguments and will not be retried if it fails. + Like all migrations, it should be idempotent and code deployed after the migration + has run should not rely on the task having been completted successfully. Only + rely on the task being queued. + + ```python + from django.db import migrations + import olympia.core.db.migrations + + class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.db.migrations.MigrationTask( + 'olympia.accounts.tasks.backfill_user_data', + ) + ] + ``` + +### Testing migrations + +You can test migrations locally. This should not be considered a safe verification that a migration will work in production because the data in your local database will differ significantly from production. But this will ensure your migration runs against the same schema and with some seeded data. + +- Find the current version of AMO on production. + + ```bash + curl -s "https://addons.mozilla.org/__version__" + ``` + +- Checkout the tag + + ```bash + git checkout + ``` + +- Make up initialized database + + ```bash + make up INIT_CLEAN=True + ``` + +- Checkout your branch and run the migrations + + ```bash + git checkout + make up + ``` + +### Deploying migrations + +Migrations are deployed to production automatically via the deployment pipeline. +Importantly, migrations are deployed before new code is deployed. That menas: + +- If a migration fails, it will cancel the deployment. +- Migrations must be backwards compatible with the previous version of the code. see [testing migrations](#testing-migrations) + +Migrations run on dedicated pods in kubernetes against the primary database. That means: + +- Changes to the database schema will not be immediately reflected across all replicas immediately after the migration is deployed. +- Long running migrations could be interupted by kubernetes and should be avoided. See [writing migrations](#writing-migrations) + +If you have an important data migration, consider shipping it in a dedicated release to ensure the database is migrated before required code changes are deployed. See [cherry-picking](https://mozilla.github.io/addons/server/push_duty/cherry-picking.html) for details. diff --git a/docs/topics/development/index.md b/docs/topics/development/index.md index e490da7501b2..3e0f486bfd59 100644 --- a/docs/topics/development/index.md +++ b/docs/topics/development/index.md @@ -29,4 +29,5 @@ waffle remote_settings ../../../README.rst authentication +database ``` diff --git a/src/olympia/accounts/migrations/0002_migration_task.py b/src/olympia/accounts/migrations/0002_migration_task.py new file mode 100644 index 000000000000..cedf2efa4197 --- /dev/null +++ b/src/olympia/accounts/migrations/0002_migration_task.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.20 on 2025-04-17 18:58 + +from django.db import migrations +import olympia.core.db.migrations + + +def migration_task(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_remove_old_2fa_waffle_switch'), + ] + + operations = [ + olympia.core.db.migrations.MigrationTask( + ), + ] diff --git a/src/olympia/amo/management/commands/run_task.py b/src/olympia/amo/management/commands/run_task.py new file mode 100644 index 000000000000..9643284c7ad7 --- /dev/null +++ b/src/olympia/amo/management/commands/run_task.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from olympia.amo.celery import app + +class Command(BaseCommand): + help = 'Queue a task to run' + + def add_arguments(self, parser): + parser.add_argument('task_name', type=str, help='Name of the task to run') + + def handle(self, *args, **options): + app.send_task(options['task_name']) diff --git a/src/olympia/core/db/migrations.py b/src/olympia/core/db/migrations.py index 620b1918a923..830639c063ec 100644 --- a/src/olympia/core/db/migrations.py +++ b/src/olympia/core/db/migrations.py @@ -27,35 +27,84 @@ def inner(apps, schema_editor): class DeleteWaffleSwitch(migrations.RunPython): def __init__(self, name, **kwargs): + self.name = name super().__init__( - delete_waffle_switch(name), - reverse_code=create_waffle_switch(name), + delete_waffle_switch(self.name), + reverse_code=create_waffle_switch(self.name), **kwargs, ) + def deconstruct(self): + return ( + self.__class__.__name__, + (self.name,), + {}, + ) + def describe(self): return 'Delete Waffle Switch (Python operation)' class CreateWaffleSwitch(migrations.RunPython): def __init__(self, name, **kwargs): + self.name = name super().__init__( - create_waffle_switch(name), - reverse_code=delete_waffle_switch(name), + create_waffle_switch(self.name), + reverse_code=delete_waffle_switch(self.name), **kwargs, ) + def deconstruct(self): + return ( + self.__class__.__name__, + (self.name,), + {}, + ) + def describe(self): return 'Create Waffle Switch (Python operation)' class RenameWaffleSwitch(migrations.RunPython): def __init__(self, old_name, new_name, **kwargs): + self.old_name = old_name + self.new_name = new_name super().__init__( - rename_waffle_switch(old_name, new_name), - reverse_code=rename_waffle_switch(new_name, old_name), + rename_waffle_switch(self.old_name, self.new_name), + reverse_code=rename_waffle_switch(self.new_name, self.old_name), **kwargs, ) + def deconstruct(self): + return ( + self.__class__.__name__, + (self.old_name, self.new_name), + {}, + ) + def describe(self): return 'Rename Waffle Switch, safely (Python operation)' + + + +class MigrationTask(migrations.RunPython): + def __init__(self, **kwargs): + self.func_name = 'migration_task' + super().__init__(self.run_task, **kwargs) + + def run_task(self, apps, schema_editor): + import importlib + from olympia.core.tasks import migration_task + + module = importlib.import_module(self.__module__) + try: + breakpoint() + func = getattr(module, self.func_name) + migration_task.apply(func) + except AttributeError: + raise ValueError(f'Function {self.func_name} not found in module {self.__module__}. Create this function in the module and re-run the migration.') + + def deconstruct(self): + return (self.__class__.__name__, (), {}) + + diff --git a/src/olympia/core/management/commands/__init__.py b/src/olympia/core/management/commands/__init__.py new file mode 100644 index 000000000000..ca20e9034851 --- /dev/null +++ b/src/olympia/core/management/commands/__init__.py @@ -0,0 +1,88 @@ +from functools import cached_property + +from django.core.management.base import BaseCommand +from django.db import connections +from django.db.migrations.graph import MigrationGraph +from django.db.migrations.loader import MigrationLoader +from django.db.migrations.migration import Migration +from django.db.migrations.operations.base import Operation +from django.db.migrations.writer import MigrationWriter + +import olympia.core.logger + + +class BaseMigrationCommand(BaseCommand): + log = olympia.core.logger.getLogger('z.core') + + def add_arguments(self, parser): + parser.add_argument( + 'app_label', + type=str, + help='The app label of the migration', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Dry run the migration', + ) + self.extend_arguments(parser) + + @cached_property + def graph(self) -> MigrationGraph: + return MigrationLoader(connections['default'], ignore_no_migrations=True).graph + + def migration(self, name: str, app_label: str) -> Migration: + return Migration(name, app_label) + + def writer(self, migration: Migration) -> MigrationWriter: + return MigrationWriter(migration) + + def print(self, filename: str, output: str) -> None: + self.stdout.write(f'{filename}: \n') + self.stdout.write(output) + + def get_name(self, *args, **options) -> str: + """ + Return the name of the migration excluding the migration number. + """ + raise NotImplementedError + + def get_operation(self, *args, **options) -> Operation: + """ + Return the operation to be performed in the migration. + """ + raise NotImplementedError + + def extend_arguments(self, parser) -> None: + """ + Extend the arguments of the command. + """ + raise NotImplementedError + + def handle(self, *args, **options): + app_label = options.get('app_label') + dry_run = options.get('dry_run', False) + name = self.get_name(*args, **options) + + leaf_nodes = self.graph.leaf_nodes(app_label) + migration_number = 1 + + if leaf_nodes and (node := leaf_nodes[-1]): + migration_number = int(node[1].split('_', 1)[0]) + 1 + + migration_name = f'{migration_number:04d}_{name}' + + migration = self.migration(migration_name, app_label) + migration.dependencies = leaf_nodes + migration.operations = [self.get_operation(*args, **options)] + + writer = self.writer(migration) + filename = writer.path + output = writer.as_string() + + if dry_run: + self.print(filename, output) + return output + + with open(filename, 'w') as f: + f.write(output) diff --git a/src/olympia/core/management/commands/migrate_task.py b/src/olympia/core/management/commands/migrate_task.py new file mode 100644 index 000000000000..15ad1336dd63 --- /dev/null +++ b/src/olympia/core/management/commands/migrate_task.py @@ -0,0 +1,24 @@ +import random +from django.db.migrations.migration import Migration +import olympia.core.logger +from olympia.core.db.migrations import ( + MigrationTask, +) +from olympia.core.management.commands import BaseMigrationCommand + + +class Command(BaseMigrationCommand): + help = 'Migrate tasks' + log = olympia.core.logger.getLogger('z.core') + + def extend_arguments(self, parser): + pass + + def get_operation(self, migration: Migration, **options): + from django.apps import apps + app = apps.get_app_config(migration.app_label) + module_path = app.module.__name__ + return MigrationTask(f'{module_path}.migrations.{migration.name}.migration_task') + + def get_name(self, *args, **options): + return str(random.randint(1000, 9999)) diff --git a/src/olympia/core/management/commands/migrate_waffle.py b/src/olympia/core/management/commands/migrate_waffle.py new file mode 100644 index 000000000000..11216168af4f --- /dev/null +++ b/src/olympia/core/management/commands/migrate_waffle.py @@ -0,0 +1,54 @@ +from enum import Enum + +from django.core.management.base import CommandError + +import olympia.core.logger +from olympia.core.db.migrations import ( + CreateWaffleSwitch, + DeleteWaffleSwitch, + RenameWaffleSwitch, +) +from olympia.core.management.commands import BaseMigrationCommand + + +class Action(Enum): + ADD = 'add' + DELETE = 'delete' + RENAME = 'rename' + + +class Command(BaseMigrationCommand): + help = 'Migrate waffle switches (add, delete, rename)' + log = olympia.core.logger.getLogger('z.core') + + def extend_arguments(self, parser): + parser.add_argument('name', type=str, help='Name of the waffle switch') + parser.add_argument( + '--action', + type=Action, + help='Action to perform (add/delete/rename)', + default=Action.ADD, + ) + parser.add_argument( + '--new-name', + type=str, + help='New name of the waffle switch', + ) + + def get_operation(self, *args, **options): + action = Action(options['action']) + name = options['name'] + new_name = options['new_name'] + + if action == Action.RENAME and not new_name: + raise CommandError('New name is required for rename action') + + if action == Action.ADD: + return CreateWaffleSwitch(name) + elif action == Action.DELETE: + return DeleteWaffleSwitch(name) + elif action == Action.RENAME: + return RenameWaffleSwitch(name, new_name) + + def get_name(self, *args, **options): + return f'waffle_{options["name"]}_{options["action"].value}' diff --git a/src/olympia/core/tasks.py b/src/olympia/core/tasks.py new file mode 100644 index 000000000000..7a612bf2e3bc --- /dev/null +++ b/src/olympia/core/tasks.py @@ -0,0 +1,7 @@ +from olympia.amo.celery import task +@task +def migration_task(func): + """ + Execute a migration script function as a task. + """ + func() diff --git a/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_dependencies_from_previous_migrations.txt b/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_dependencies_from_previous_migrations.txt new file mode 100644 index 000000000000..0322bd0dabea --- /dev/null +++ b/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_dependencies_from_previous_migrations.txt @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-04-04 04:04 + +from django.db import migrations +import olympia.core.tests.test_commands + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_test_name'), + ('core', '0002_test_name'), + ] + + operations = [ + olympia.core.tests.test_commands.test_operation( + name='test_argument', + ), + ] diff --git a/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_specified_operations.txt b/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_specified_operations.txt new file mode 100644 index 000000000000..9a39f4d89a94 --- /dev/null +++ b/src/olympia/core/tests/snapshots/TestBaseMigrationCommand/test_adds_specified_operations.txt @@ -0,0 +1,16 @@ +# Generated by Django 4.2.20 on 2025-04-04 04:04 + +from django.db import migrations +import olympia.core.tests.test_commands + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.tests.test_commands.test_operation( + name='test_argument', + ), + ] diff --git a/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_add_waffle_switch.txt b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_add_waffle_switch.txt new file mode 100644 index 000000000000..f9bfe549b400 --- /dev/null +++ b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_add_waffle_switch.txt @@ -0,0 +1,16 @@ +# Generated by Django 4.2.20 on 2025-04-04 04:04 + +from django.db import migrations +import olympia.core.db.migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.db.migrations.CreateWaffleSwitch( + name='test_switch', + ), + ] diff --git a/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_delete_waffle_switch.txt b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_delete_waffle_switch.txt new file mode 100644 index 000000000000..43a6943f1062 --- /dev/null +++ b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_delete_waffle_switch.txt @@ -0,0 +1,16 @@ +# Generated by Django 4.2.20 on 2025-04-04 04:04 + +from django.db import migrations +import olympia.core.db.migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.db.migrations.DeleteWaffleSwitch( + name='test_switch', + ), + ] diff --git a/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_rename_waffle_switch.txt b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_rename_waffle_switch.txt new file mode 100644 index 000000000000..03b68ff66516 --- /dev/null +++ b/src/olympia/core/tests/snapshots/TestMigrateWaffle/test_rename_waffle_switch.txt @@ -0,0 +1,17 @@ +# Generated by Django 4.2.20 on 2025-04-04 04:04 + +from django.db import migrations +import olympia.core.db.migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + olympia.core.db.migrations.RenameWaffleSwitch( + old_name='test_switch', + new_name='new_test_switch', + ), + ] diff --git a/src/olympia/core/tests/test_commands.py b/src/olympia/core/tests/test_commands.py new file mode 100644 index 000000000000..3866f208b5c5 --- /dev/null +++ b/src/olympia/core/tests/test_commands.py @@ -0,0 +1,191 @@ +import tempfile +from unittest import mock + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db.migrations.operations.base import Operation + +from freezegun import freeze_time +from pyparsing import Path + +from olympia.amo.tests import TestCase +from olympia.core.management.commands import BaseMigrationCommand +from olympia.core.management.commands.migrate_waffle import Action + + +class TestSnapshotMixin: + def assertMatchesSnapshot(self, result, snapshot_dir=__file__): + snapshot_dir = Path(snapshot_dir).parent / 'snapshots' / self.__class__.__name__ + snapshot_dir.mkdir(parents=True, exist_ok=True) + name = self._testMethodName + + snapshot_path = snapshot_dir / f'{name}.txt' + + if not snapshot_path.exists(): + snapshot_path.write_text(result) + + with snapshot_path.open('r') as f: + snapshot = f.read() + + self.assertEqual(result, snapshot) + + +@freeze_time('2025-04-04 04:04') +class TestBaseMigrationCommand(TestCase, TestSnapshotMixin): + def setUp(self): + patch_graph = mock.patch( + 'olympia.core.management.commands.BaseMigrationCommand.graph' + ) + self.graph = patch_graph.start() + self.graph.leaf_nodes.return_value = [] + self.addCleanup(patch_graph.stop) + + patch_stdout = mock.patch( + 'olympia.core.management.commands.BaseMigrationCommand.print' + ) + self.print = patch_stdout.start() + self.addCleanup(patch_stdout.stop) + + class TestOperation(Operation): + def __init__(self, name, *args, **kwargs): + self.name = name + super().__init__(*args, **kwargs) + + def deconstruct(self): + return ( + 'test_operation', + [self.name], + {}, + ) + + class TestCommand(BaseMigrationCommand): + def get_name(self, *args, **options): + return 'test_name' + + def get_operation(self, *args, **options): + return TestBaseMigrationCommand.TestOperation('test_argument') + + def extend_arguments(self, parser): + parser.add_argument('--test-argument', type=str, help='Test argument') + + def test_unimplemented_methods_should_raise(self): + class Command(BaseMigrationCommand): + pass + + with self.assertRaises(NotImplementedError): + Command().handle() + + with self.assertRaises(NotImplementedError): + Command().get_name() + + with self.assertRaises(NotImplementedError): + Command().get_operation() + + with self.assertRaises(NotImplementedError): + Command().extend_arguments(None) + + def test_get_migration_name(self): + command = self.TestCommand() + self.assertEqual(command.get_name(), 'test_name') + + def test_adds_specified_operations(self): + command = self.TestCommand() + command.handle(dry_run=True, app_label='core') + + filename, output = self.print.call_args_list[0][0] + assert filename.endswith('0001_test_name.py') + self.assertMatchesSnapshot(output) + + def test_includes_extended_arguments(self): + command = self.TestCommand() + mock_add_argument = mock.MagicMock() + mock_parser = mock.Mock() + mock_parser.add_argument.side_effect = mock_add_argument + command.add_arguments(mock_parser) + + assert ( + mock.call('--test-argument', type=str, help='Test argument') + in mock_add_argument.call_args_list + ) + + def test_correct_migration_file(self): + """ + Set's the writer.path to a temporary directory and expects + the migration file to be written there. + """ + tmp_dir = tempfile.mkdtemp() + output_path = Path(tmp_dir) / '0001_test_name.py' + mock_writer = mock.MagicMock() + mock_writer.path = output_path.as_posix() + mock_writer.as_string.return_value = 'mock migration file' + + with mock.patch( + 'olympia.core.management.commands.BaseMigrationCommand.writer' + ) as patch_writer: + patch_writer.return_value = mock_writer + + command = self.TestCommand() + command.handle(app_label='core') + + assert output_path.exists() + assert output_path.read_text() == 'mock migration file' + + def test_adds_dependencies_from_previous_migrations(self): + migrations = [('core', f'{x:04d}_test_name') for x in range(1, 3)] + self.graph.leaf_nodes.return_value = migrations + + command = self.TestCommand() + command.handle(app_label='core', dry_run=True) + filename, output = self.print.call_args_list[0][0] + assert filename.endswith('0003_test_name.py') + self.assertMatchesSnapshot(output) + + +@freeze_time('2025-04-04 04:04') +class TestMigrateWaffle(TestCase, TestSnapshotMixin): + def test_missing_required_arguments_should_raise(self): + test_cases = [ + ('migrate_waffle', {}), + ('migrate_waffle', 'fake_app', {}), + ('migrate_waffle', 'core', {}), + ('migrate_waffle', 'core', 'test_switch', {'action': Action.RENAME}), + ] + + for args in test_cases: + args, kwargs = args[:-1], args[-1] + with ( + self.subTest(args=args, kwargs=kwargs), + self.assertRaises(CommandError), + ): + call_command(*args, **kwargs, dry_run=True) + + def test_add_waffle_switch(self): + output = call_command( + 'migrate_waffle', + 'core', + 'test_switch', + action=Action.ADD, + dry_run=True, + ) + self.assertMatchesSnapshot(output) + + def test_delete_waffle_switch(self): + output = call_command( + 'migrate_waffle', + 'core', + 'test_switch', + action=Action.DELETE, + dry_run=True, + ) + self.assertMatchesSnapshot(output) + + def test_rename_waffle_switch(self): + output = call_command( + 'migrate_waffle', + 'core', + 'test_switch', + action=Action.RENAME, + new_name='new_test_switch', + dry_run=True, + ) + self.assertMatchesSnapshot(output)