Skip to content

Commit 7602c67

Browse files
fix: More Backup Restore Fixes (#2859)
* refactor normalized search migration to use dummy default * changed group slug migration to use raw SQL * updated comment * added tests with anonymized backups (currently failing) * typo * fixed LDAP enum in test data * fix for adding label settings across groups * add migration data fixes * fix shopping list label settings test * re-run db init instead of just running alembic migration, to include fixes * intentionally broke SQLAlchemy GUID handling * safely convert between GUID types in different databases * restore original test data after testing backup restores * added missing group name update to migration
1 parent b3f7f2d commit 7602c67

14 files changed

+421
-44
lines changed

alembic/versions/2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
"""
88
import sqlalchemy as sa
99
from sqlalchemy import orm, select
10-
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
10+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
1111
from text_unidecode import unidecode
1212

1313
import mealie.db.migration_types
1414
from alembic import op
15-
1615
from mealie.db.models._model_utils import GUID
1716

1817
# revision identifiers, used by Alembic.
@@ -52,30 +51,46 @@ def do_data_migration():
5251
session = orm.Session(bind=bind)
5352

5453
recipes = session.execute(select(RecipeModel)).scalars().all()
55-
ingredients = session.execute(select(RecipeIngredient)).scalars().all()
5654
for recipe in recipes:
5755
if recipe.name is not None:
58-
recipe.name_normalized = unidecode(recipe.name).lower().strip()
56+
session.execute(
57+
sa.text(
58+
f"UPDATE {RecipeModel.__tablename__} SET name_normalized=:name_normalized WHERE id=:id"
59+
).bindparams(name_normalized=unidecode(recipe.name).lower().strip(), id=recipe.id)
60+
)
5961

6062
if recipe.description is not None:
61-
recipe.description_normalized = unidecode(recipe.description).lower().strip()
62-
session.add(recipe)
63+
session.execute(
64+
sa.text(
65+
f"UPDATE {RecipeModel.__tablename__} SET description_normalized=:description_normalized WHERE id=:id"
66+
).bindparams(description_normalized=unidecode(recipe.description).lower().strip(), id=recipe.id)
67+
)
6368

69+
ingredients = session.execute(select(RecipeIngredient)).scalars().all()
6470
for ingredient in ingredients:
6571
if ingredient.note is not None:
66-
ingredient.note_normalized = unidecode(ingredient.note).lower().strip()
72+
session.execute(
73+
sa.text(
74+
f"UPDATE {RecipeIngredient.__tablename__} SET note_normalized=:note_normalized WHERE id=:id"
75+
).bindparams(note_normalized=unidecode(ingredient.note).lower().strip(), id=ingredient.id)
76+
)
6777

6878
if ingredient.original_text is not None:
69-
ingredient.original_text_normalized = unidecode(ingredient.original_text).lower().strip()
70-
session.add(ingredient)
79+
session.execute(
80+
sa.text(
81+
f"UPDATE {RecipeIngredient.__tablename__} SET original_text_normalized=:original_text_normalized WHERE id=:id"
82+
).bindparams(
83+
original_text_normalized=unidecode(ingredient.original_text).lower().strip(), id=ingredient.id
84+
)
85+
)
7186
session.commit()
7287

7388

7489
def upgrade():
7590
# ### commands auto generated by Alembic - please adjust! ###
7691

77-
# Set column to nullable first, since we do not have values here yet
78-
op.add_column("recipes", sa.Column("name_normalized", sa.String(), nullable=True))
92+
# Set column default first, since we do not have values here yet
93+
op.add_column("recipes", sa.Column("name_normalized", sa.String(), nullable=False, server_default=""))
7994
op.add_column("recipes", sa.Column("description_normalized", sa.String(), nullable=True))
8095
op.drop_index("ix_recipes_description", table_name="recipes")
8196
op.drop_index("ix_recipes_name", table_name="recipes")
@@ -95,9 +110,9 @@ def upgrade():
95110
unique=False,
96111
)
97112
do_data_migration()
98-
# Make recipes.name_normalized not nullable now that column should be filled for all rows
113+
# Remove server default now that column should be filled for all rows
99114
with op.batch_alter_table("recipes", schema=None) as batch_op:
100-
batch_op.alter_column("name_normalized", nullable=False, existing_type=sa.String())
115+
batch_op.alter_column("name_normalized", existing_type=sa.String(), server_default=None)
101116
# ### end Alembic commands ###
102117

103118

alembic/versions/2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424

2525
def populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table: sa.Table, session: Session):
2626
shopping_lists = session.query(ShoppingList).all()
27-
labels = session.query(MultiPurposeLabel).all()
2827

2928
shopping_lists_labels_data: list[dict] = []
3029
for shopping_list in shopping_lists:
30+
labels = session.query(MultiPurposeLabel).filter(MultiPurposeLabel.group_id == ShoppingList.group_id).all()
3131
for i, label in enumerate(labels):
3232
shopping_lists_labels_data.append(
3333
{"id": uuid4(), "shopping_list_id": shopping_list.id, "label_id": label.id, "position": i}

alembic/versions/2023-08-06-21.00.34_04ac51cbe9a4_added_group_slug.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,22 @@ def populate_group_slugs(session: Session):
2424
seen_slugs: set[str] = set()
2525
for group in groups:
2626
original_name = group.name
27+
new_name = original_name
2728
attempts = 0
2829
while True:
29-
slug = slugify(group.name)
30+
slug = slugify(new_name)
3031
if slug not in seen_slugs:
3132
break
3233

3334
attempts += 1
34-
group.name = f"{original_name} ({attempts})"
35+
new_name = f"{original_name} ({attempts})"
3536

3637
seen_slugs.add(slug)
37-
group.slug = slug
38+
session.execute(
39+
sa.text(f"UPDATE {Group.__tablename__} SET name=:name, slug=:slug WHERE id=:id").bindparams(
40+
name=new_name, slug=slug, id=group.id
41+
)
42+
)
3843

3944
session.commit()
4045

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import json
2+
import logging
3+
import random
4+
import string
5+
from datetime import datetime
6+
from uuid import UUID
7+
8+
logger = logging.getLogger("anonymize_backups")
9+
10+
11+
def is_uuid4(value: str):
12+
try:
13+
UUID(value)
14+
return True
15+
except ValueError:
16+
return False
17+
18+
19+
def is_iso_datetime(value: str):
20+
try:
21+
datetime.fromisoformat(value)
22+
return True
23+
except ValueError:
24+
return False
25+
26+
27+
def random_string(length=10):
28+
return "".join(random.choice(string.ascii_lowercase) for _ in range(length))
29+
30+
31+
def clean_value(value):
32+
try:
33+
match value:
34+
# preserve non-strings
35+
case int(value) | float(value):
36+
return value
37+
case None:
38+
return value
39+
# preserve UUIDs and datetimes
40+
case str(value) if is_uuid4(value) or is_iso_datetime(value):
41+
return value
42+
# randomize strings
43+
case str(value):
44+
return random_string()
45+
case _:
46+
pass
47+
48+
except Exception as e:
49+
logger.exception(e)
50+
51+
logger.error(f"Failed to anonymize value: {value}")
52+
return value
53+
54+
55+
def walk_data_and_anonymize(data):
56+
for k, v in data.items():
57+
if isinstance(v, list):
58+
for item in v:
59+
walk_data_and_anonymize(item)
60+
else:
61+
# preserve alembic version number and enums
62+
if k in ["auth_method", "version_num"]:
63+
continue
64+
65+
data[k] = clean_value(v)
66+
67+
68+
def anonymize_database_json(input_filepath: str, output_filepath: str):
69+
with open(input_filepath) as f:
70+
data = json.load(f)
71+
72+
walk_data_and_anonymize(data)
73+
with open(output_filepath, "w") as f:
74+
json.dump(data, f)

mealie/db/fixes/fix_migration_data.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from uuid import uuid4
2+
3+
from slugify import slugify
4+
from sqlalchemy.orm import Session
5+
6+
from mealie.core import root_logger
7+
from mealie.db.models.group.group import Group
8+
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
9+
from mealie.db.models.labels import MultiPurposeLabel
10+
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
11+
from mealie.db.models.recipe.recipe import RecipeModel
12+
13+
logger = root_logger.get_logger("init_db")
14+
15+
16+
def fix_recipe_normalized_search_properties(session: Session):
17+
recipes = session.query(RecipeModel).all()
18+
recipes_fixed = False
19+
20+
for recipe in recipes:
21+
add_to_session = False
22+
if recipe.name and not recipe.name_normalized:
23+
recipe.name_normalized = RecipeModel.normalize(recipe.name)
24+
add_to_session = True
25+
if recipe.description and not recipe.description_normalized:
26+
recipe.description_normalized = RecipeModel.normalize(recipe.description)
27+
add_to_session = True
28+
29+
for ingredient in recipe.recipe_ingredient:
30+
if ingredient.note and not ingredient.note_normalized:
31+
ingredient.note_normalized = RecipeModel.normalize(ingredient.note)
32+
add_to_session = True
33+
if ingredient.original_text and not ingredient.original_text_normalized:
34+
ingredient.original_text = RecipeModel.normalize(ingredient.original_text_normalized)
35+
add_to_session = True
36+
37+
if add_to_session:
38+
recipes_fixed = True
39+
session.add(recipe)
40+
41+
if recipes_fixed:
42+
logger.info("Updating recipe normalized search properties")
43+
session.commit()
44+
45+
46+
def fix_shopping_list_label_settings(session: Session):
47+
shopping_lists = session.query(ShoppingList).all()
48+
labels = session.query(MultiPurposeLabel).all()
49+
label_settings_fixed = False
50+
51+
for shopping_list in shopping_lists:
52+
labels_by_id = {label.id: label for label in labels if label.group_id == shopping_list.group_id}
53+
for label_setting in shopping_list.label_settings:
54+
if not labels_by_id.pop(label_setting.label_id, None):
55+
# label setting is no longer valid, so delete it
56+
session.delete(label_setting)
57+
label_settings_fixed = True
58+
59+
if not labels_by_id:
60+
# all labels are accounted for, so we don't need to add any
61+
continue
62+
63+
label_settings_fixed = True
64+
for i, label in enumerate(labels_by_id.values()):
65+
new_label_setting = ShoppingListMultiPurposeLabel(
66+
id=uuid4(),
67+
shopping_list_id=shopping_list.id,
68+
label_id=label.id,
69+
position=i + len(shopping_list.label_settings),
70+
)
71+
72+
session.add(new_label_setting)
73+
74+
if label_settings_fixed:
75+
logger.info("Fixing shopping list label settings")
76+
session.commit()
77+
78+
79+
def fix_group_slugs(session: Session):
80+
groups = session.query(Group).all()
81+
seen_slugs: set[str] = set()
82+
groups_fixed = False
83+
84+
for group in groups:
85+
if not group.slug:
86+
original_name = group.name
87+
new_name = original_name
88+
attempts = 0
89+
while True:
90+
slug = slugify(group.name)
91+
if slug not in seen_slugs:
92+
break
93+
94+
attempts += 1
95+
new_name = f"{original_name} ({attempts})"
96+
97+
groups_fixed = True
98+
group.name = new_name
99+
group.slug = slug
100+
101+
if groups_fixed:
102+
logger.info("Adding missing group slugs")
103+
session.commit()
104+
105+
106+
def fix_normalized_unit_and_food_names(session: Session):
107+
units = session.query(IngredientUnitModel).all()
108+
units_fixed = False
109+
110+
for unit in units:
111+
add_to_session = False
112+
if unit.name and not unit.name_normalized:
113+
unit.name_normalized = IngredientUnitModel.normalize(unit.name)
114+
add_to_session = True
115+
if unit.abbreviation and not unit.abbreviation_normalized:
116+
unit.abbreviation_normalized = IngredientUnitModel.normalize(unit.abbreviation)
117+
add_to_session = True
118+
119+
if add_to_session:
120+
units_fixed = True
121+
session.add(unit)
122+
123+
if units_fixed:
124+
logger.info("Updating unit normalized search properties")
125+
session.commit()
126+
127+
foods = session.query(IngredientFoodModel).all()
128+
foods_fixed = False
129+
130+
for food in foods:
131+
add_to_session = False
132+
if food.name and not food.name_normalized:
133+
food.name_normalized = IngredientFoodModel.normalize(food.name)
134+
add_to_session = True
135+
136+
if add_to_session:
137+
foods_fixed = True
138+
session.add(food)
139+
140+
if foods_fixed:
141+
logger.info("Updating food normalized search properties")
142+
session.commit()
143+
144+
145+
def fix_migration_data(session: Session):
146+
logger.info("Checking for migration data fixes")
147+
fix_recipe_normalized_search_properties(session)
148+
fix_shopping_list_label_settings(session)
149+
fix_group_slugs(session)
150+
fix_normalized_unit_and_food_names(session)

mealie/db/init_db.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mealie.core.config import get_app_settings
1212
from mealie.db.db_setup import session_context
1313
from mealie.db.fixes.fix_group_with_no_name import fix_group_with_no_name
14+
from mealie.db.fixes.fix_migration_data import fix_migration_data
1415
from mealie.db.fixes.fix_slug_foods import fix_slug_food_names
1516
from mealie.repos.all_repositories import get_repositories
1617
from mealie.repos.repository_factory import AllRepositories
@@ -97,16 +98,16 @@ def main():
9798
session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
9899

99100
db = get_repositories(session)
101+
safe_try(lambda: fix_migration_data(session))
102+
safe_try(lambda: fix_slug_food_names(db))
103+
safe_try(lambda: fix_group_with_no_name(session))
100104

101105
if db.users.get_all():
102106
logger.debug("Database exists")
103107
else:
104108
logger.info("Database contains no users, initializing...")
105109
init_db(db)
106110

107-
safe_try(lambda: fix_slug_food_names(db))
108-
safe_try(lambda: fix_group_with_no_name(session))
109-
110111

111112
if __name__ == "__main__":
112113
main()

0 commit comments

Comments
 (0)