"""Normalize ingredient.aisle and grocery_item.aisle to canonical labels. Revision ID: 0015 Revises: 0014 Create Date: 2026-06-02 Normalizes free-text aisle values on `ingredient.aisle` and `grocery_item.aisle` to a fixed canonical set. Runs in a single transaction (Alembic default); both op.execute calls share the same session, so the temp backup tables persist for the duration of the upgrade. Backup tables: the temp tables `ingredient_aisle_backup` and `grocery_item_aisle_backup` are created for the migration's session. They auto-drop when the session ends. If you need a persistent backup, run `backend/scripts/persist_aisle_backup.sql` BEFORE this migration. Deploy via Docker (the db runs inside a container; no host psql required): # 1. dry-run preview: docker compose exec db psql -U mealplanner -d mealplanner \\ -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql # 2. apply: docker compose exec backend alembic upgrade head """ from typing import Sequence, Union from alembic import op # revision identifiers, used by Alembic. revision: str = "0015" down_revision: Union[str, None] = "0014" branch_labels: Union[Sequence[str], None] = None depends_on: Union[Sequence[str], None] = None NORMALIZATION_RULES = [ ("canned goods", "Pantry"), ("canned", "Pantry"), ("freezer", "Frozen"), ("frozen", "Frozen"), ("produce", "Produce"), ("fruit", "Produce"), ("vegetable", "Produce"), ("dairy", "Dairy & Eggs"), ("eggs", "Dairy & Eggs"), ("cheese", "Dairy & Eggs"), ("milk", "Dairy & Eggs"), ("yogurt", "Dairy & Eggs"), ("meat", "Meat & Seafood"), ("seafood", "Meat & Seafood"), ("fish", "Meat & Seafood"), ("chicken", "Meat & Seafood"), ("beef", "Meat & Seafood"), ("pork", "Meat & Seafood"), ("meat_seafood", "Meat & Seafood"), ("bakery", "Bakery"), ("bread", "Bakery"), ("beverage", "Beverages"), ("beverages", "Beverages"), ("drinks", "Beverages"), ("spice", "Spices"), ("spices", "Spices"), ("seasoning", "Spices"), ("pantry", "Pantry"), ("dry", "Pantry"), ("snack", "Pantry"), ("snacks", "Pantry"), ] CASE_EXPR = ( "CASE LOWER(COALESCE(aisle::text, '')) " + " ".join(f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES) + " WHEN '' THEN NULL " + " ELSE 'Other' END" ) def _normalize(table: str) -> None: op.execute( f"CREATE TEMP TABLE {table}_aisle_backup AS " f"SELECT id, aisle FROM {table} WHERE aisle IS NOT NULL" ) # Filter to non-NULL aisle rows so the SET target type is the column's # varchar(100) and matches the CASE expression's inferred type. op.execute( f"UPDATE {table} SET aisle = {CASE_EXPR}::varchar(100) " f"WHERE aisle IS NOT NULL" ) def upgrade() -> None: _normalize("ingredient") _normalize("grocery_item") def downgrade() -> None: # The temp backup tables only exist for the migration's session. # Restoring the pre-normalization state is not possible from this # migration alone. Operators must restore from a database snapshot # taken before upgrade. raise NotImplementedError( "Cannot reverse aisle normalization without an external backup. " "Restore the database from a snapshot taken before 0015 was applied." )