diff --git a/mealie/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py b/mealie/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py index 38c4bba6f..3e10b4874 100644 --- a/mealie/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py +++ b/mealie/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py @@ -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. diff --git a/mealie/db/fixes/fix_migration_data.py b/mealie/db/fixes/fix_migration_data.py index 2ec83cae2..1f3470f5c 100644 --- a/mealie/db/fixes/fix_migration_data.py +++ b/mealie/db/fixes/fix_migration_data.py @@ -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 diff --git a/mealie/db/models/_all_models.py b/mealie/db/models/_all_models.py index 38aded7fd..66209a07d 100644 --- a/mealie/db/models/_all_models.py +++ b/mealie/db/models/_all_models.py @@ -1,5 +1,4 @@ from .group import * -from .labels import * from .recipe import * from .server import * from .users import * diff --git a/mealie/db/models/_filterable_column.py b/mealie/db/models/_filterable_column.py new file mode 100644 index 000000000..d1ed8af28 --- /dev/null +++ b/mealie/db/models/_filterable_column.py @@ -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 diff --git a/mealie/db/models/_model_base.py b/mealie/db/models/_model_base.py index ef9b7cacc..ecdbb7992 100644 --- a/mealie/db/models/_model_base.py +++ b/mealie/db/models/_model_base.py @@ -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]: diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index 3738bbc9b..12589cbd0 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -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) diff --git a/mealie/db/models/household/cookbook.py b/mealie/db/models/household/cookbook.py index e824f3584..d06cf8694 100644 --- a/mealie/db/models/household/cookbook.py +++ b/mealie/db/models/household/cookbook.py @@ -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: diff --git a/mealie/db/models/household/household.py b/mealie/db/models/household/household.py index 5df432b5d..717e8a555 100644 --- a/mealie/db/models/household/household.py +++ b/mealie/db/models/household/household.py @@ -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") diff --git a/mealie/db/models/household/mealplan.py b/mealie/db/models/household/mealplan.py index 0131000d8..86401aac2 100644 --- a/mealie/db/models/household/mealplan.py +++ b/mealie/db/models/household/mealplan.py @@ -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 ) diff --git a/mealie/db/models/household/preferences.py b/mealie/db/models/household/preferences.py index 6951f0260..551798ee7 100644 --- a/mealie/db/models/household/preferences.py +++ b/mealie/db/models/household/preferences.py @@ -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: diff --git a/mealie/db/models/household/shopping_list.py b/mealie/db/models/household/shopping_list.py index 1a2d38290..4e75582fe 100644 --- a/mealie/db/models/household/shopping_list.py +++ b/mealie/db/models/household/shopping_list.py @@ -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", diff --git a/mealie/db/models/recipe/api_extras.py b/mealie/db/models/recipe/api_extras.py index eed2c2805..b1b58cd06 100644 --- a/mealie/db/models/recipe/api_extras.py +++ b/mealie/db/models/recipe/api_extras.py @@ -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 ) diff --git a/mealie/db/models/recipe/assets.py b/mealie/db/models/recipe/assets.py index ccdd3c106..2102650c6 100644 --- a/mealie/db/models/recipe/assets.py +++ b/mealie/db/models/recipe/assets.py @@ -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 diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index a017fc225..cfda2ad96 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -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" ) diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index 82e6f89af..375bcddf1 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -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__( diff --git a/mealie/db/models/labels.py b/mealie/db/models/recipe/labels.py similarity index 57% rename from mealie/db/models/labels.py rename to mealie/db/models/recipe/labels.py index 47f9a6066..d67c25e8b 100644 --- a/mealie/db/models/labels.py +++ b/mealie/db/models/recipe/labels.py @@ -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") diff --git a/mealie/db/models/recipe/nutrition.py b/mealie/db/models/recipe/nutrition.py index 619748389..3681e6e10 100644 --- a/mealie/db/models/recipe/nutrition.py +++ b/mealie/db/models/recipe/nutrition.py @@ -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, diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 327b8cd6e..0a5dc7ba8 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -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={ diff --git a/mealie/db/models/recipe/recipe_timeline.py b/mealie/db/models/recipe/recipe_timeline.py index 57fdc9f22..5bd8a8171 100644 --- a/mealie/db/models/recipe/recipe_timeline.py +++ b/mealie/db/models/recipe/recipe_timeline.py @@ -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__( diff --git a/mealie/db/models/recipe/settings.py b/mealie/db/models/recipe/settings.py index 2e412e5b1..70c88ab33 100644 --- a/mealie/db/models/recipe/settings.py +++ b/mealie/db/models/recipe/settings.py @@ -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) diff --git a/mealie/db/models/recipe/shared.py b/mealie/db/models/recipe/shared.py index 7f2634a57..941fca708 100644 --- a/mealie/db/models/recipe/shared.py +++ b/mealie/db/models/recipe/shared.py @@ -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: diff --git a/mealie/db/models/recipe/tag.py b/mealie/db/models/recipe/tag.py index 57925cbd6..86669a494 100644 --- a/mealie/db/models/recipe/tag.py +++ b/mealie/db/models/recipe/tag.py @@ -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" ) diff --git a/mealie/db/models/recipe/tool.py b/mealie/db/models/recipe/tool.py index 59a578419..b914197cb 100644 --- a/mealie/db/models/recipe/tool.py +++ b/mealie/db/models/recipe/tool.py @@ -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" diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 9dece804a..6c3c92c02 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -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") diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index 3fda48423..9dfde3897 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -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 diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 414574105..40a8a8062 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -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, diff --git a/mealie/services/query_filter/builder.py b/mealie/services/query_filter/builder.py index a213ff652..04dbacd87 100644 --- a/mealie/services/query_filter/builder.py +++ b/mealie/services/query_filter/builder.py @@ -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 diff --git a/tests/unit_tests/repository_tests/test_query_filter_builder.py b/tests/unit_tests/repository_tests/test_query_filter_builder.py index 9015a4a52..545a4b533 100644 --- a/tests/unit_tests/repository_tests/test_query_filter_builder.py +++ b/tests/unit_tests/repository_tests/test_query_filter_builder.py @@ -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 diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py index c367df2d9..82236c932 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py @@ -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