"""Normalize ingredient.aisle and grocery_item.aisle to canonical labels. Revision ID: 0015 Revises: 0014 Create Date: 2026-06-02 """ from typing import Sequence, Union import sqlalchemy as sa 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 CANONICAL_AISLES = ( "Produce", "Meat & Seafood", "Dairy & Eggs", "Pantry", "Frozen", "Bakery", "Beverages", "Spices", "Other", ) # Map from lowercased source value to canonical label. Keep the rule # order narrow -> broad; longest matches win via SQL CASE. 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, '')) " + " ".join( f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES ) + " WHEN NULLIF(LOWER(COALESCE(aisle, '')), '') IS NULL 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" ) op.execute(f"UPDATE {table} SET aisle = {CASE_EXPR} WHERE aisle IS NOT NULL") def upgrade() -> None: bind = op.get_bind() with op.batch_alter_table("ingredient") as batch: pass _normalize("ingredient") _normalize("grocery_item") bind.execute( sa.text( "SELECT set_config('app.aisle_backup_retention', 'aisle_migration_0015', false)" ) ) def downgrade() -> None: # Best-effort downgrade: the backup temp tables only exist within the # upgrade transaction. 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." )