fix: Protect sensitive data in query filter API (GHSA-8m57-7cv5-rjp8) (#7629)

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson
2026-05-21 16:08:41 -05:00
committed by GitHub
parent 493154caa8
commit 642c826f2b
29 changed files with 387 additions and 246 deletions

View File

@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session, load_only
from alembic import op
from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
# revision identifiers, used by Alembic.

View File

@@ -8,8 +8,8 @@ from mealie.core import root_logger
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.group.group import Group
from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import User

View File

@@ -1,5 +1,4 @@
from .group import *
from .labels import *
from .recipe import *
from .server import *
from .users import *

View File

@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
from sqlalchemy.orm import Mapped, mapped_column
class _FilterableColumn[T]:
"""
Drop-in replacement for `Mapped[]` that marks a column as filterable.
Filterable columns can be used in query filter expressions.
Only valid on scalar column fields. Using it on a relationship type (e.g. `list[Model]`).
"""
def __class_getitem__(cls, item: type) -> type:
return Mapped[Annotated[item, mapped_column(info={"filterable": True})]]
# SQLAlchemy doesn't play nice with mypy when overriding Mapped, so
# we use this awkward workaround to make mypy happy
if TYPE_CHECKING:
FilterableColumn = Mapped
else:
FilterableColumn = _FilterableColumn

View File

@@ -5,6 +5,7 @@ from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
from text_unidecode import unidecode
from ._filterable_column import FilterableColumn
from ._model_utils.datetime import NaiveDateTime, get_utc_now
# Punctuation characters replaced with spaces during text normalization.
@@ -16,8 +17,10 @@ _NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NO
class SqlAlchemyBase(DeclarativeBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now)
created_at: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
update_at: FilterableColumn[datetime | None] = mapped_column(
NaiveDateTime, default=get_utc_now, onupdate=get_utc_now
)
@declared_attr
def updated_at(cls) -> Mapped[datetime | None]:

View File

@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..household.cookbook import CookBook
@@ -31,9 +31,9 @@ if TYPE_CHECKING:
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils import guid
from .._model_utils.auto_init import auto_init
from ..recipe.category import Category, cookbooks_to_categories
@@ -21,31 +21,31 @@ class CookBook(SqlAlchemyBase, BaseMixins):
UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"),
)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=1)
group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks")
household_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks")
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String, default="")
public: Mapped[str | None] = mapped_column(Boolean, default=False)
name: FilterableColumn[str] = mapped_column(String, nullable=False)
slug: FilterableColumn[str] = mapped_column(String, nullable=False, index=True)
description: FilterableColumn[str | None] = mapped_column(String, default="")
public: FilterableColumn[str | None] = mapped_column(Boolean, default=False)
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
# Old filters - deprecated in favor of query filter strings
categories: Mapped[list[Category]] = orm.relationship(
Category, secondary=cookbooks_to_categories, single_parent=True
)
require_all_categories: Mapped[bool | None] = mapped_column(Boolean, default=True)
require_all_categories: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
require_all_tags: Mapped[bool | None] = mapped_column(Boolean, default=True)
require_all_tags: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True)
require_all_tools: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
@auto_init()
def __init__(self, **_) -> None:

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..recipe.ingredient import households_to_ingredient_foods
@@ -33,9 +33,9 @@ class Household(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"),
)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
@@ -48,7 +48,7 @@ class Household(SqlAlchemyBase, BaseMixins):
cascade="all, delete-orphan",
)
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="households")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="household")

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..recipe.category import Category, plan_rules_to_categories
@@ -30,14 +30,14 @@ plan_rules_to_households = Table(
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
household_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
day: Mapped[str] = mapped_column(
day: FilterableColumn[str] = mapped_column(
String, nullable=False, default="unset"
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type: Mapped[str] = mapped_column(
entry_type: FilterableColumn[str] = mapped_column(
String, nullable=False, default=""
) # "breakfast", "lunch", "dinner", etc ...
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
@@ -55,19 +55,19 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans"
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False)
title: Mapped[str] = mapped_column(String, index=True, nullable=False)
text: Mapped[str] = mapped_column(String, nullable=False)
date: FilterableColumn[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
title: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
text: FilterableColumn[str] = mapped_column(String, nullable=False)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", back_populates="meal_entries", uselist=False
)

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
@@ -15,27 +15,29 @@ if TYPE_CHECKING:
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "household_preferences"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
household_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("households.id"), nullable=False, index=True)
household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("households.id"), nullable=False, index=True
)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences")
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True)
private_household: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
show_announcements: FilterableColumn[bool] = mapped_column(sa.Boolean, default=True)
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
lock_recipe_edits_from_other_households: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: FilterableColumn[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_public: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated
recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_disable_amount: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
@auto_init()
def __init__(self, **_) -> None:

View File

@@ -8,10 +8,10 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
@@ -25,18 +25,20 @@ if TYPE_CHECKING:
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship(
"ShoppingListItem", back_populates="recipe_references"
)
shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
shopping_list_item_id: FilterableColumn[GUID] = mapped_column(
GUID, ForeignKey("shopping_list_items.id"), primary_key=True
)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
recipe_scale: Mapped[float] = mapped_column(Float, default=1)
recipe_note: Mapped[str | None] = mapped_column(String)
recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
recipe_scale: FilterableColumn[float] = mapped_column(Float, default=1)
recipe_note: FilterableColumn[str | None] = mapped_column(String)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
@@ -50,33 +52,33 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
# Id's
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items")
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
shopping_list_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
# Meta
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: Mapped[bool | None] = mapped_column(Boolean, default=False)
is_ingredient: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
quantity: Mapped[float | None] = mapped_column(Float, default=1)
note: Mapped[str | None] = mapped_column(String)
quantity: FilterableColumn[float | None] = mapped_column(Float, default=1)
note: FilterableColumn[str | None] = mapped_column(String)
extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
"ShoppingListItemExtras", cascade="all, delete-orphan"
)
# Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label: Mapped[MultiPurposeLabel | None] = orm.relationship(
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
)
@@ -98,19 +100,19 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references")
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", uselist=False, back_populates="shopping_list_refs"
)
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
model_config = ConfigDict(exclude={"id", "recipe"})
@auto_init()
@@ -121,12 +123,12 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists_multi_purpose_labels"
__table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label: Mapped["MultiPurposeLabel"] = orm.relationship(
"MultiPurposeLabel", back_populates="shopping_lists_label_settings"
)
@@ -134,7 +136,7 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0)
model_config = ConfigDict(exclude={"label"})
@auto_init()
@@ -144,16 +146,16 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists")
name: Mapped[str | None] = mapped_column(String)
name: FilterableColumn[str | None] = mapped_column(String)
list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
ShoppingListItem,
cascade="all, delete, delete-orphan",

View File

@@ -1,7 +1,7 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
@@ -28,9 +28,9 @@ class ExtrasGeneric:
This class is not an actual table, so it does not inherit from SqlAlchemyBase
"""
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
key_name: Mapped[str | None] = mapped_column(sa.String)
value: Mapped[str | None] = mapped_column(sa.String)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
key_name: FilterableColumn[str | None] = mapped_column(sa.String)
value: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, key, value) -> None:
self.key_name = key
@@ -40,21 +40,25 @@ class ExtrasGeneric:
# used specifically for recipe extras
class ApiExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "api_extras"
recipee_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
recipee_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
class IngredientFoodExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "ingredient_food_extras"
ingredient_food_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("ingredient_foods.id"), index=True)
ingredient_food_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("ingredient_foods.id"), index=True
)
class ShoppingListExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_extras"
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("shopping_lists.id"), index=True)
shopping_list_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_lists.id"), index=True
)
class ShoppingListItemExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_item_extras"
shopping_list_item_id: Mapped[GUID | None] = mapped_column(
shopping_list_item_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_list_items.id"), index=True
)

View File

@@ -1,17 +1,17 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: Mapped[str | None] = mapped_column(sa.String)
icon: Mapped[str | None] = mapped_column(sa.String)
file_name: Mapped[str | None] = mapped_column(sa.String)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: FilterableColumn[str | None] = mapped_column(sa.String)
icon: FilterableColumn[str | None] = mapped_column(sa.String)
file_name: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, name=None, icon=None, file_name=None) -> None:
self.name = name

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.guid import GUID
if TYPE_CHECKING:
@@ -54,12 +54,12 @@ class Category(SqlAlchemyBase, BaseMixins):
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
# ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category"
)

View File

@@ -6,9 +6,9 @@ from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
@@ -29,19 +29,19 @@ households_to_ingredient_foods = sa.Table(
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
abbreviation: Mapped[str | None] = mapped_column(String)
plural_abbreviation: Mapped[str | None] = mapped_column(String)
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False)
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True)
name: FilterableColumn[str | None] = mapped_column(String)
plural_name: FilterableColumn[str | None] = mapped_column(String)
description: FilterableColumn[str | None] = mapped_column(String)
abbreviation: FilterableColumn[str | None] = mapped_column(String)
plural_abbreviation: FilterableColumn[str | None] = mapped_column(String)
use_abbreviation: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
fraction: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="unit"
@@ -53,14 +53,14 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
)
# Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String)
standard_quantity: FilterableColumn[float | None] = mapped_column(Float)
standard_unit: FilterableColumn[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init()
def __init__(
@@ -152,18 +152,18 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
)
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
name: FilterableColumn[str | None] = mapped_column(String)
plural_name: FilterableColumn[str | None] = mapped_column(String)
description: FilterableColumn[str | None] = mapped_column(String)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food"
@@ -175,12 +175,12 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
)
extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan")
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True)
label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True)
label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods")
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
exclude={
@@ -261,15 +261,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
unit_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True)
unit_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True)
unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String)
name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init()
def __init__(self, session: Session, name: str, **_) -> None:
@@ -302,15 +302,15 @@ class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
food_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True)
food_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True)
food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String)
name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init()
def __init__(self, session: Session, name: str, **_) -> None:
@@ -343,34 +343,34 @@ class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
position: Mapped[int | None] = mapped_column(Integer, index=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
id: FilterableColumn[int] = mapped_column(Integer, primary_key=True)
position: FilterableColumn[int | None] = mapped_column(Integer, index=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
title: FilterableColumn[str | None] = mapped_column(String) # Section Header - Shows if Present
note: FilterableColumn[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
# Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True)
unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True)
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True)
food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True)
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
quantity: Mapped[float | None] = mapped_column(Float)
quantity: FilterableColumn[float | None] = mapped_column(Float)
original_text: Mapped[str | None] = mapped_column(String)
original_text: FilterableColumn[str | None] = mapped_column(String)
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links
reference_id: FilterableColumn[GUID | None] = mapped_column(GUID) # Reference Links
# Recipe Reference
referenced_recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe: Mapped["RecipeModel"] = orm.relationship(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
)
# Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)
note_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
original_text_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init()
def __init__(

View File

@@ -3,26 +3,26 @@ from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ._model_utils.auto_init import auto_init
from ._model_utils.guid import GUID
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group.group import Group
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from .recipe import IngredientFoodModel
from ..group.group import Group
from ..household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from . import IngredientFoodModel
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels"
__table_args__ = (UniqueConstraint("name", "group_id", name="multi_purpose_labels_name_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(10), nullable=False, default="")
id: FilterableColumn[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name: FilterableColumn[str] = mapped_column(String(255), nullable=False)
color: FilterableColumn[str] = mapped_column(String(10), nullable=False, default="")
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="labels")
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")

View File

@@ -1,22 +1,22 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
calories: Mapped[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
cholesterol_content: Mapped[str | None] = mapped_column(sa.String)
fat_content: Mapped[str | None] = mapped_column(sa.String)
fiber_content: Mapped[str | None] = mapped_column(sa.String)
protein_content: Mapped[str | None] = mapped_column(sa.String)
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
calories: FilterableColumn[str | None] = mapped_column(sa.String)
carbohydrate_content: FilterableColumn[str | None] = mapped_column(sa.String)
cholesterol_content: FilterableColumn[str | None] = mapped_column(sa.String)
fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
fiber_content: FilterableColumn[str | None] = mapped_column(sa.String)
protein_content: FilterableColumn[str | None] = mapped_column(sa.String)
saturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
# `serving_size` is not a scaling factor, but a per-serving volume or mass
# according to schema.org. E.g., "2 L", "500 g", "5 cups", etc.
@@ -28,10 +28,10 @@ class Nutrition(SqlAlchemyBase):
#
# serving_size: Mapped[str | None] = mapped_column(sa.String)
sodium_content: Mapped[str | None] = mapped_column(sa.String)
sugar_content: Mapped[str | None] = mapped_column(sa.String)
trans_fat_content: Mapped[str | None] = mapped_column(sa.String)
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
sodium_content: FilterableColumn[str | None] = mapped_column(sa.String)
sugar_content: FilterableColumn[str | None] = mapped_column(sa.String)
trans_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
unsaturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(
self,

View File

@@ -16,7 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras
@@ -45,20 +45,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),
)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
# ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rating: FilterableColumn[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rated_by: Mapped[list["User"]] = orm.relationship(
"User",
secondary=UserToRecipe.__tablename__,
@@ -78,20 +78,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
# General Recipe Properties
name: Mapped[str] = mapped_column(sa.String, nullable=False)
description: Mapped[str | None] = mapped_column(sa.String)
name: FilterableColumn[str] = mapped_column(sa.String, nullable=False)
description: FilterableColumn[str | None] = mapped_column(sa.String)
image: Mapped[str | None] = mapped_column(sa.String)
image: FilterableColumn[str | None] = mapped_column(sa.String)
# Time Related Properties
total_time: Mapped[str | None] = mapped_column(sa.String)
prep_time: Mapped[str | None] = mapped_column(sa.String)
perform_time: Mapped[str | None] = mapped_column(sa.String)
cook_time: Mapped[str | None] = mapped_column(sa.String)
total_time: FilterableColumn[str | None] = mapped_column(sa.String)
prep_time: FilterableColumn[str | None] = mapped_column(sa.String)
perform_time: FilterableColumn[str | None] = mapped_column(sa.String)
cook_time: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield: Mapped[str | None] = mapped_column(sa.String)
recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
recipe_yield: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield_quantity: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
recipe_servings: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
@@ -137,14 +137,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
org_url: Mapped[str | None] = mapped_column(sa.String)
org_url: FilterableColumn[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
# Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
date_added: FilterableColumn[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
last_made: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
made_by: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
)
@@ -162,8 +162,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
name_normalized: FilterableColumn[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
get_attr="slug",
exclude={

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_utils.datetime import NaiveDateTime
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
@@ -18,29 +18,29 @@ if TYPE_CHECKING:
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_timeline_events"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# Parent Recipe
recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# Related User (Actor)
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = relationship(
"User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
)
# General Properties
subject: Mapped[str] = mapped_column(String, nullable=False)
message: Mapped[str | None] = mapped_column(String)
event_type: Mapped[str | None] = mapped_column(String)
image: Mapped[str | None] = mapped_column(String)
subject: FilterableColumn[str] = mapped_column(String, nullable=False)
message: FilterableColumn[str | None] = mapped_column(String)
event_type: FilterableColumn[str | None] = mapped_column(String)
image: FilterableColumn[str | None] = mapped_column(String)
# Timestamps
timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True)
timestamp: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, index=True)
@auto_init()
def __init__(

View File

@@ -1,20 +1,20 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: Mapped[bool | None] = mapped_column(sa.Boolean)
show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean)
show_assets: Mapped[bool | None] = mapped_column(sa.Boolean)
landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean)
disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
locked: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated
disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)

View File

@@ -3,9 +3,9 @@ from typing import TYPE_CHECKING
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
@@ -22,12 +22,12 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4)
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
recipe_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False)
expires_at: FilterableColumn[datetime] = mapped_column(NaiveDateTime, nullable=False)
@auto_init()
def __init__(self, **_) -> None:

View File

@@ -6,7 +6,7 @@ from slugify import slugify
from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils import guid
if TYPE_CHECKING:
@@ -44,14 +44,16 @@ cookbooks_to_tags = sa.Table(
class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
# ID Relationships
group_id: Mapped[guid.GUID] = mapped_column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[guid.GUID] = mapped_column(
guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True
)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tags, back_populates="tags"
)

View File

@@ -5,7 +5,7 @@ from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.guid import GUID
@@ -42,14 +42,14 @@ cookbooks_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools"
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
name: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
slug: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
households_with_tool: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_tools, back_populates="tools_on_hand"

View File

@@ -13,7 +13,7 @@ from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .user_to_recipe import UserToRecipe
if TYPE_CHECKING:
@@ -50,18 +50,21 @@ class AuthMethod(enum.Enum):
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name: Mapped[str | None] = mapped_column(String, index=True)
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name: FilterableColumn[str | None] = mapped_column(String, index=True)
username: FilterableColumn[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="users")
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), nullable=True, index=True)
household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, ForeignKey("households.id"), nullable=True, index=True
)
household: Mapped["Household"] = orm.relationship("Household", back_populates="users")
cache_key: Mapped[str | None] = mapped_column(String, default="1234")

View File

@@ -25,10 +25,10 @@ from mealie.db.models.household.shopping_list import (
ShoppingListRecipeReference,
)
from mealie.db.models.household.webhooks import GroupWebhooksModel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent
from mealie.db.models.recipe.shared import RecipeShareTokenModel

View File

@@ -25,7 +25,7 @@ from mealie.schema.response.pagination import (
RequestQuery,
)
from mealie.schema.response.query_search import SearchFilter
from mealie.services.query_filter.builder import QueryFilterBuilder
from mealie.services.query_filter.builder import NonFilterableValueError, QueryFilterBuilder
from ._utils import NOT_SET, NotSet
@@ -467,6 +467,12 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
query, order_attr, order_dir, request_query.order_by_null_position
)
except NonFilterableValueError as e:
raise HTTPException(
status_code=400,
detail=f'Invalid order_by statement "{request_query.order_by}": {e.message}',
) from e
except ValueError as e:
raise HTTPException(
status_code=400,

View File

@@ -22,6 +22,17 @@ from .keywords import PlaceholderKeyword, RelationalKeyword
from .operators import LogicalOperator, RelationalOperator
class NonFilterableValueError(ValueError):
"""Raised when trying to filter by an unfilterable field"""
def __init__(self, field: str):
self.message = f"Cannot filter on {field}"
super().__init__(self.message)
def __str__(self):
return f"{self.message}"
class QueryFilterJSONPart(MealieModel):
left_parenthesis: str | None = None
right_parenthesis: str | None = None
@@ -202,7 +213,7 @@ class QueryFilterBuilder:
@classmethod
def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
) -> tuple[type[SqlAlchemyBase], InstrumentedAttribute, sa.Select | None]:
"""
Take an attribute string and traverse a database model and its relationships to get the desired
model and model attribute. Optionally provide a query to apply the necessary table joins.
@@ -222,7 +233,7 @@ class QueryFilterBuilder:
if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty")
current_model: SqlAlchemyBase = model # type: ignore
current_model: type[SqlAlchemyBase] = model
for i, attribute_link in enumerate(attribute_chain):
try:
model_attr = getattr(current_model, attribute_link)
@@ -259,6 +270,9 @@ class QueryFilterBuilder:
if model_attr is None:
raise ValueError(f"invalid attribute string: '{attr_string}'")
if not getattr(model_attr, "info", {}).get("filterable"):
raise NonFilterableValueError(model_attr)
return current_model, model_attr, query
@classmethod
@@ -334,7 +348,7 @@ class QueryFilterBuilder:
column_aliases = column_aliases or {}
# join tables and build model chain
attr_model_map: dict[int, Any] = {}
attr_map: dict[int, tuple[type[SqlAlchemyBase], InstrumentedAttribute]] = {}
model_attr: InstrumentedAttribute
for i, component in enumerate(self.filter_components):
if not isinstance(component, QueryFilterBuilderComponent):
@@ -343,7 +357,7 @@ class QueryFilterBuilder:
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
component.attribute_name, model, query=query
)
attr_model_map[i] = nested_model
attr_map[i] = (nested_model, model_attr)
# build query filter
partial_group: list[sa.ColumnElement] = []
@@ -367,9 +381,9 @@ class QueryFilterBuilder:
else:
component = cast(QueryFilterBuilderComponent, component)
base_attribute_name = component.attribute_name.split(".")[-1]
model_attr = getattr(attr_model_map[i], base_attribute_name)
nested_model, model_attr = attr_map[i]
base_attribute_name = component.attribute_name.split(".")[-1]
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
model_attr = column_alias

View File

@@ -1,5 +1,11 @@
import pytest
import sqlalchemy as sa
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import LongLiveToken, User
from mealie.services.query_filter.builder import (
LogicalOperator,
NonFilterableValueError,
QueryFilterBuilder,
QueryFilterJSON,
QueryFilterJSONPart,
@@ -74,3 +80,80 @@ def test_query_filter_builder_json_uses_raw_value():
),
]
)
# ---------------------------------------------------------------------------
# FilterableColumn tests
# ---------------------------------------------------------------------------
def test_non_filterable_field_user_password_raises():
"""Filtering on User.password (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("password", User)
def test_non_filterable_field_user_email_raises():
"""Filtering on User.email (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("email", User)
def test_non_filterable_field_long_live_token_raises():
"""Filtering on LongLiveToken.token (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("token", LongLiveToken)
def test_filterable_field_does_not_raise():
"""Filtering on a FilterableColumn field should not raise."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("full_name", User)
assert model is User
assert attr is User.full_name
# ---------------------------------------------------------------------------
# Relationship traversal tests
# ---------------------------------------------------------------------------
def test_deep_traversal_to_filterable_field_works():
"""Traversing a relationship to a FilterableColumn field should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.full_name", RecipeModel)
assert model is User
assert attr is User.full_name
def test_deep_traversal_to_non_filterable_field_raises():
"""Traversing a relationship to a plain Mapped field should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.email", RecipeModel)
def test_deep_traversal_user_password_raises():
"""Traversing RecipeModel.user.password should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.password", RecipeModel)
def test_filter_query_user_email_raises():
"""filter_query on user.email should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.email = "test@example.com"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_filter_query_user_password_raises():
"""filter_query on user.password should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.password = "secret"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_association_proxy_resolving_to_filterable_field_works():
"""Single-hop association proxy (e.g. household_id) resolving to a FilterableColumn should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("household_id", RecipeModel)
assert model is User
assert attr is User.household_id

View File

@@ -13,8 +13,8 @@ from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.mealplan import GroupMealPlanRules
from mealie.db.models.household.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe