Coverage for opt/mealie/lib/python3.12/site-packages/mealie/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py: 61%
139 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
1"""added unique constraints
3Revision ID: dded3119c1fe
4Revises: 0341b154f79a
5Create Date: 2023-10-04 14:29:26.688065
7"""
9from collections import defaultdict 1a
10from dataclasses import dataclass 1a
11from typing import Any 1a
13import sqlalchemy as sa 1a
14from pydantic import UUID4 1a
15from sqlalchemy import orm 1a
16from sqlalchemy.orm import Session, load_only 1a
18from alembic import op 1a
19from mealie.db.models._model_utils.guid import GUID 1a
20from mealie.db.models.labels import MultiPurposeLabel 1a
21from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel 1a
23# revision identifiers, used by Alembic.
24revision = "dded3119c1fe" 1a
25down_revision = "0341b154f79a" 1a
26branch_labels: str | tuple[str, ...] | None = None 1a
27depends_on: str | tuple[str, ...] | None = None 1a
30# Intermediate table definitions
31class SqlAlchemyBase(orm.DeclarativeBase): 1a
32 pass 1a
35class ShoppingList(SqlAlchemyBase): 1a
36 __tablename__ = "shopping_lists" 1a
38 id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) 1a
39 group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) 1a
42class ShoppingListItem(SqlAlchemyBase): 1a
43 __tablename__ = "shopping_list_items" 1a
45 id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) 1a
46 food_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_foods.id")) 1a
47 unit_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_units.id")) 1a
48 label_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("multi_purpose_labels.id")) 1a
51@dataclass 1a
52class TableMeta: 1a
53 tablename: str 1a
54 pk_1: str 1a
55 pk_2: str 1a
57 @classmethod 1a
58 def composite_pk(self, pk_1_val: Any, pk_2_val: Any) -> str: 1a
59 return "$$".join([pk_1_val, pk_2_val])
62def _is_postgres(): 1a
63 return op.get_context().dialect.name == "postgresql" 1a
66def _get_duplicates(session: Session, model: orm.DeclarativeBase) -> defaultdict[str, list]: 1a
67 duplicate_map: defaultdict[str, list] = defaultdict(list) 1a
69 query = session.execute(sa.text(f"SELECT id, group_id, name FROM {model.__tablename__}")) 1a
70 for row in query.all(): 70 ↛ 71line 70 didn't jump to line 71 because the loop on line 70 never started1a
71 id, group_id, name = row
72 key = f"{group_id}$${name}"
73 duplicate_map[key].append(id)
75 return duplicate_map 1a
78def _resolve_duplicate_food( 1a
79 session: Session,
80 keep_food_id: UUID4,
81 dupe_food_id: UUID4,
82):
83 for shopping_list_item in (
84 session.query(ShoppingListItem)
85 .options(load_only(ShoppingListItem.id, ShoppingListItem.food_id))
86 .filter_by(food_id=dupe_food_id)
87 .all()
88 ):
89 shopping_list_item.food_id = keep_food_id
91 for recipe_ingredient in (
92 session.query(RecipeIngredientModel)
93 .options(load_only(RecipeIngredientModel.id, RecipeIngredientModel.food_id))
94 .filter_by(food_id=dupe_food_id)
95 .all()
96 ):
97 recipe_ingredient.food_id = keep_food_id
99 session.commit()
100 session.execute(
101 sa.text(f"DELETE FROM {IngredientFoodModel.__tablename__} WHERE id=:id").bindparams(id=dupe_food_id)
102 )
103 session.commit()
106def _resolve_duplicate_unit( 1a
107 session: Session,
108 keep_unit_id: UUID4,
109 dupe_unit_id: UUID4,
110):
111 for shopping_list_item in (
112 session.query(ShoppingListItem)
113 .options(load_only(ShoppingListItem.id, ShoppingListItem.unit_id))
114 .filter_by(unit_id=dupe_unit_id)
115 .all()
116 ):
117 shopping_list_item.unit_id = keep_unit_id
119 for recipe_ingredient in (
120 session.query(RecipeIngredientModel)
121 .options(load_only(RecipeIngredientModel.id, RecipeIngredientModel.unit_id))
122 .filter_by(unit_id=dupe_unit_id)
123 .all()
124 ):
125 recipe_ingredient.unit_id = keep_unit_id
127 session.commit()
128 session.execute(
129 sa.text(f"DELETE FROM {IngredientUnitModel.__tablename__} WHERE id=:id").bindparams(id=dupe_unit_id)
130 )
131 session.commit()
134def _resolve_duplicate_label( 1a
135 session: Session,
136 keep_label_id: UUID4,
137 dupe_label_id: UUID4,
138):
139 for shopping_list_item in (
140 session.query(ShoppingListItem)
141 .options(load_only(ShoppingListItem.id, ShoppingListItem.label_id))
142 .filter_by(label_id=dupe_label_id)
143 .all()
144 ):
145 shopping_list_item.label_id = keep_label_id
147 for ingredient_food in (
148 session.query(IngredientFoodModel)
149 .options(load_only(IngredientFoodModel.id, IngredientFoodModel.label_id))
150 .filter_by(label_id=dupe_label_id)
151 .all()
152 ):
153 ingredient_food.label_id = keep_label_id
155 session.commit()
156 session.execute(sa.text(f"DELETE FROM {MultiPurposeLabel.__tablename__} WHERE id=:id").bindparams(id=dupe_label_id))
157 session.commit()
160def _resolve_duplicate_foods_units_labels(session: Session): 1a
161 for model, resolve_func in [ 1a
162 (IngredientFoodModel, _resolve_duplicate_food),
163 (IngredientUnitModel, _resolve_duplicate_unit),
164 (MultiPurposeLabel, _resolve_duplicate_label),
165 ]:
166 duplicate_map = _get_duplicates(session, model) 1a
167 for ids in duplicate_map.values(): 167 ↛ 168line 167 didn't jump to line 168 because the loop on line 167 never started1a
168 if len(ids) < 2:
169 continue
171 keep_id = ids[0]
172 for dupe_id in ids[1:]:
173 resolve_func(session, keep_id, dupe_id)
176def _remove_duplicates_from_m2m_table(session: Session, table_meta: TableMeta): 1a
177 if _is_postgres(): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true1a
178 default_pk = "CTID"
179 else:
180 default_pk = "ROWID" 1a
182 # some of these tables are missing defined unique pks, so we have to rely on the database default pk
183 query = sa.text( 1a
184 f"""
185 DELETE FROM {table_meta.tablename}
186 WHERE EXISTS (
187 SELECT 1 FROM {table_meta.tablename} t2
188 WHERE {table_meta.tablename}.{table_meta.pk_1} = t2.{table_meta.pk_1}
189 AND {table_meta.tablename}.{table_meta.pk_2} = t2.{table_meta.pk_2}
190 AND {table_meta.tablename}.{default_pk} > t2.{default_pk}
191 )
192 """
193 )
195 session.execute(query) 1a
196 session.commit() 1a
199def _remove_duplicates_from_m2m_tables(session: Session, table_metas: list[TableMeta]): 1a
200 for table_meta in table_metas: 1a
201 _remove_duplicates_from_m2m_table(session, table_meta) 1a
204def upgrade(): 1a
205 bind = op.get_bind() 1a
206 session = Session(bind=bind) 1a
208 _resolve_duplicate_foods_units_labels(session) 1a
209 _remove_duplicates_from_m2m_tables( 1a
210 session,
211 [
212 TableMeta("cookbooks_to_categories", "cookbook_id", "category_id"),
213 TableMeta("cookbooks_to_tags", "cookbook_id", "tag_id"),
214 TableMeta("cookbooks_to_tools", "cookbook_id", "tool_id"),
215 TableMeta("group_to_categories", "group_id", "category_id"),
216 TableMeta("plan_rules_to_categories", "group_plan_rule_id", "category_id"),
217 TableMeta("plan_rules_to_tags", "plan_rule_id", "tag_id"),
218 TableMeta("recipes_to_categories", "recipe_id", "category_id"),
219 TableMeta("recipes_to_tags", "recipe_id", "tag_id"),
220 TableMeta("recipes_to_tools", "recipe_id", "tool_id"),
221 TableMeta("users_to_favorites", "user_id", "recipe_id"),
222 TableMeta("shopping_lists_multi_purpose_labels", "shopping_list_id", "label_id"),
223 ],
224 )
226 session.commit() 1a
228 # ### commands auto generated by Alembic - please adjust! ###
229 # we use batch_alter_table here because otherwise this fails on sqlite
230 # M2M
231 with op.batch_alter_table("cookbooks_to_categories") as batch_op: 1a
232 batch_op.create_unique_constraint("cookbook_id_category_id_key", ["cookbook_id", "category_id"]) 1a
234 with op.batch_alter_table("cookbooks_to_tags") as batch_op: 1a
235 batch_op.create_unique_constraint("cookbook_id_tag_id_key", ["cookbook_id", "tag_id"]) 1a
237 with op.batch_alter_table("cookbooks_to_tools") as batch_op: 1a
238 batch_op.create_unique_constraint("cookbook_id_tool_id_key", ["cookbook_id", "tool_id"]) 1a
240 with op.batch_alter_table("group_to_categories") as batch_op: 1a
241 batch_op.create_unique_constraint("group_id_category_id_key", ["group_id", "category_id"]) 1a
243 with op.batch_alter_table("plan_rules_to_categories") as batch_op: 1a
244 batch_op.create_unique_constraint("group_plan_rule_id_category_id_key", ["group_plan_rule_id", "category_id"]) 1a
246 with op.batch_alter_table("plan_rules_to_tags") as batch_op: 1a
247 batch_op.create_unique_constraint("plan_rule_id_tag_id_key", ["plan_rule_id", "tag_id"]) 1a
249 with op.batch_alter_table("recipes_to_categories") as batch_op: 1a
250 batch_op.create_unique_constraint("recipe_id_category_id_key", ["recipe_id", "category_id"]) 1a
252 with op.batch_alter_table("recipes_to_tags") as batch_op: 1a
253 batch_op.create_unique_constraint("recipe_id_tag_id_key", ["recipe_id", "tag_id"]) 1a
255 with op.batch_alter_table("recipes_to_tools") as batch_op: 1a
256 batch_op.create_unique_constraint("recipe_id_tool_id_key", ["recipe_id", "tool_id"]) 1a
258 with op.batch_alter_table("users_to_favorites") as batch_op: 1a
259 batch_op.create_unique_constraint("user_id_recipe_id_key", ["user_id", "recipe_id"]) 1a
261 with op.batch_alter_table("shopping_lists_multi_purpose_labels") as batch_op: 1a
262 batch_op.create_unique_constraint("shopping_list_id_label_id_key", ["shopping_list_id", "label_id"]) 1a
264 # Foods/Units/Labels
265 with op.batch_alter_table("ingredient_foods") as batch_op: 1a
266 batch_op.create_unique_constraint("ingredient_foods_name_group_id_key", ["name", "group_id"]) 1a
268 with op.batch_alter_table("ingredient_units") as batch_op: 1a
269 batch_op.create_unique_constraint("ingredient_units_name_group_id_key", ["name", "group_id"]) 1a
271 with op.batch_alter_table("multi_purpose_labels") as batch_op: 1a
272 batch_op.create_unique_constraint("multi_purpose_labels_name_group_id_key", ["name", "group_id"]) 1a
274 op.create_index( 1a
275 op.f("ix_shopping_lists_multi_purpose_labels_created_at"),
276 "shopping_lists_multi_purpose_labels",
277 ["created_at"],
278 unique=False,
279 )
280 # ### end Alembic commands ###
283def downgrade(): 1a
284 # ### commands auto generated by Alembic - please adjust! ###
286 # M2M
287 op.drop_constraint("user_id_recipe_id_key", "users_to_favorites", type_="unique")
288 op.drop_index(
289 op.f("ix_shopping_lists_multi_purpose_labels_created_at"), table_name="shopping_lists_multi_purpose_labels"
290 )
291 op.drop_constraint("recipe_id_tool_id_key", "recipes_to_tools", type_="unique")
292 op.drop_constraint("recipe_id_tag_id_key", "recipes_to_tags", type_="unique")
293 op.drop_constraint("recipe_id_category_id_key", "recipes_to_categories", type_="unique")
294 op.drop_constraint("plan_rule_id_tag_id_key", "plan_rules_to_tags", type_="unique")
295 op.drop_constraint("group_plan_rule_id_category_id_key", "plan_rules_to_categories", type_="unique")
296 op.drop_constraint("group_id_category_id_key", "group_to_categories", type_="unique")
297 op.drop_constraint("cookbook_id_tool_id_key", "cookbooks_to_tools", type_="unique")
298 op.drop_constraint("cookbook_id_tag_id_key", "cookbooks_to_tags", type_="unique")
299 op.drop_constraint("cookbook_id_category_id_key", "cookbooks_to_categories", type_="unique")
300 op.drop_constraint("shopping_list_id_label_id_key", "shopping_lists_multi_purpose_labels", type_="unique")
302 # Foods/Units/Labels
303 op.drop_constraint("multi_purpose_labels_name_group_id_key", "multi_purpose_labels", type_="unique")
304 op.drop_constraint("ingredient_units_name_group_id_key", "ingredient_units", type_="unique")
305 op.drop_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", type_="unique")
306 # ### end Alembic commands ###