mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-09 03:45:15 -05:00
feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)
This commit is contained in:
@@ -33,7 +33,9 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
@@ -40,8 +40,9 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
entry_type: Mapped[str] = mapped_column(
|
||||
String, nullable=False, default=""
|
||||
) # "breakfast", "lunch", "dinner", "side"
|
||||
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||
|
||||
# Filters
|
||||
# Old filters - deprecated in favor of query filter strings
|
||||
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
|
||||
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
||||
households: Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
|
||||
|
||||
@@ -10,7 +10,7 @@ from ._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .group.group import Group
|
||||
from .group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from .recipe import IngredientFoodModel
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery
|
||||
from mealie.schema.response.query_filter import QueryFilter
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||
from mealie.schema.response.query_search import SearchFilter
|
||||
|
||||
from ._utils import NOT_SET, NotSet
|
||||
@@ -349,8 +349,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
|
||||
if pagination.query_filter:
|
||||
try:
|
||||
query_filter = QueryFilter(pagination.query_filter)
|
||||
query = query_filter.filter_query(query, model=self.model)
|
||||
query_filter_builder = QueryFilterBuilder(pagination.query_filter)
|
||||
query = query_filter_builder.filter_query(query, model=self.model)
|
||||
|
||||
except ValueError as e:
|
||||
self.logger.error(e)
|
||||
@@ -434,7 +434,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
order_by = order_by_val
|
||||
order_dir = pagination.order_direction
|
||||
|
||||
_, order_attr, query = QueryFilter.get_model_and_model_attr_from_attr_string(
|
||||
_, order_attr, query = QueryFilterBuilder.get_model_and_model_attr_from_attr_string(
|
||||
order_by, self.model, query=query
|
||||
)
|
||||
|
||||
|
||||
@@ -19,13 +19,7 @@ from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import (
|
||||
RecipeCategory,
|
||||
RecipePagination,
|
||||
RecipeSummary,
|
||||
RecipeTool,
|
||||
)
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
|
||||
from mealie.schema.response.pagination import (
|
||||
OrderByNullPosition,
|
||||
OrderDirection,
|
||||
@@ -33,7 +27,6 @@ from mealie.schema.response.pagination import (
|
||||
)
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from ..schema._mealie.mealie_model import extract_uuids
|
||||
from .repository_generic import HouseholdRepositoryGeneric
|
||||
|
||||
|
||||
@@ -173,17 +166,12 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
q = q.filter_by(**fltr)
|
||||
|
||||
if cookbook:
|
||||
cb_filters = self._build_recipe_filter(
|
||||
households=[cookbook.household_id],
|
||||
categories=extract_uuids(cookbook.categories),
|
||||
tags=extract_uuids(cookbook.tags),
|
||||
tools=extract_uuids(cookbook.tools),
|
||||
require_all_categories=cookbook.require_all_categories,
|
||||
require_all_tags=cookbook.require_all_tags,
|
||||
require_all_tools=cookbook.require_all_tools,
|
||||
)
|
||||
|
||||
q = q.filter(*cb_filters)
|
||||
if pagination_result.query_filter and cookbook.query_filter_string:
|
||||
pagination_result.query_filter = (
|
||||
f"({pagination_result.query_filter}) AND ({cookbook.query_filter_string})"
|
||||
)
|
||||
else:
|
||||
pagination_result.query_filter = cookbook.query_filter_string
|
||||
else:
|
||||
category_ids = self._uuids_for_items(categories, Category)
|
||||
tag_ids = self._uuids_for_items(tags, Tag)
|
||||
@@ -290,26 +278,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
fltr.append(RecipeModel.household_id.in_(households))
|
||||
return fltr
|
||||
|
||||
def by_category_and_tags(
|
||||
self,
|
||||
categories: list[CategoryBase] | None = None,
|
||||
tags: list[TagBase] | None = None,
|
||||
tools: list[RecipeTool] | None = None,
|
||||
require_all_categories: bool = True,
|
||||
require_all_tags: bool = True,
|
||||
require_all_tools: bool = True,
|
||||
) -> list[Recipe]:
|
||||
fltr = self._build_recipe_filter(
|
||||
categories=extract_uuids(categories) if categories else None,
|
||||
tags=extract_uuids(tags) if tags else None,
|
||||
tools=extract_uuids(tools) if tools else None,
|
||||
require_all_categories=require_all_categories,
|
||||
require_all_tags=require_all_tags,
|
||||
require_all_tools=require_all_tools,
|
||||
)
|
||||
stmt = sa.select(RecipeModel).filter(*fltr)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random(self, limit=1) -> list[Recipe]:
|
||||
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
|
||||
if self.group_id:
|
||||
|
||||
@@ -3,7 +3,6 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
|
||||
@@ -59,15 +58,12 @@ class PublicCookbooksController(BasePublicHouseholdExploreController):
|
||||
if not household or household.preferences.private_household:
|
||||
raise NOT_FOUND_EXCEPTION
|
||||
|
||||
# limit recipes to only the household the cookbook belongs to
|
||||
recipes_repo = get_repositories(
|
||||
self.session, group_id=self.group_id, household_id=cookbook.household_id
|
||||
).recipes
|
||||
recipes = recipes_repo.page_all(
|
||||
cross_household_recipes = self.cross_household_repos.recipes
|
||||
recipes = cross_household_recipes.page_all(
|
||||
PaginationQuery(
|
||||
page=1,
|
||||
per_page=-1,
|
||||
query_filter="settings.public = TRUE",
|
||||
query_filter="settings.public = TRUE AND household.preferences.privateHousehold = FALSE",
|
||||
),
|
||||
cookbook=cookbook,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import orjson
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
|
||||
@@ -40,7 +39,6 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
||||
households: list[UUID4 | str] | None = Query(None),
|
||||
) -> PaginationBase[RecipeSummary]:
|
||||
cookbook_data: ReadCookBook | None = None
|
||||
recipes_repo = self.cross_household_recipes
|
||||
if search_query.cookbook:
|
||||
COOKBOOK_NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
|
||||
if isinstance(search_query.cookbook, UUID):
|
||||
@@ -59,18 +57,13 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
||||
if not household or household.preferences.private_household:
|
||||
raise COOKBOOK_NOT_FOUND_EXCEPTION
|
||||
|
||||
# filter recipes by the cookbook's household
|
||||
recipes_repo = get_repositories(
|
||||
self.session, group_id=self.group_id, household_id=cookbook_data.household_id
|
||||
).recipes
|
||||
|
||||
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
|
||||
if q.query_filter:
|
||||
q.query_filter = f"({q.query_filter}) AND {public_filter}"
|
||||
else:
|
||||
q.query_filter = public_filter
|
||||
|
||||
pagination_response = recipes_repo.page_all(
|
||||
pagination_response = self.cross_household_recipes.page_all(
|
||||
pagination=q,
|
||||
cookbook=cookbook_data,
|
||||
categories=categories,
|
||||
|
||||
@@ -109,17 +109,8 @@ class GroupCookbookController(BaseCrudController):
|
||||
if cookbook is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return cookbook.cast(
|
||||
RecipeCookBook,
|
||||
recipes=self.repos.recipes.by_category_and_tags(
|
||||
cookbook.categories,
|
||||
cookbook.tags,
|
||||
cookbook.tools,
|
||||
cookbook.require_all_categories,
|
||||
cookbook.require_all_tags,
|
||||
cookbook.require_all_tools,
|
||||
),
|
||||
)
|
||||
recipe_pagination = self.repos.recipes.page_all(PaginationQuery(page=1, per_page=-1, cookbook=cookbook))
|
||||
return cookbook.cast(RecipeCookBook, recipes=recipe_pagination.items)
|
||||
|
||||
@router.put("/{item_id}", response_model=ReadCookBook)
|
||||
def update_one(self, item_id: str, data: CreateCookBook):
|
||||
|
||||
@@ -12,7 +12,7 @@ from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
||||
from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
|
||||
from mealie.schema.meal_plan.plan_rules import PlanCategory, PlanHousehold, PlanRulesDay, PlanTag
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
@@ -54,31 +54,15 @@ class GroupMealplanController(BaseCrudController):
|
||||
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
|
||||
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||
|
||||
tags: list[PlanTag] = []
|
||||
categories: list[PlanCategory] = []
|
||||
households: list[PlanHousehold] = []
|
||||
for rule in rules:
|
||||
if rule.tags:
|
||||
tags.extend(rule.tags)
|
||||
if rule.categories:
|
||||
categories.extend(rule.categories)
|
||||
if rule.households:
|
||||
households.extend(rule.households)
|
||||
|
||||
if not (tags or categories or households):
|
||||
return cross_household_recipes.get_random(limit=limit)
|
||||
|
||||
category_ids = [category.id for category in categories] or None
|
||||
tag_ids = [tag.id for tag in tags] or None
|
||||
household_ids = [household.id for household in households] or None
|
||||
|
||||
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
|
||||
recipes_data = cross_household_recipes.page_all(
|
||||
pagination=PaginationQuery(
|
||||
page=1, per_page=limit, order_by="random", pagination_seed=self.repo._random_seed()
|
||||
),
|
||||
categories=category_ids,
|
||||
tags=tag_ids,
|
||||
households=household_ids,
|
||||
page=1,
|
||||
per_page=limit,
|
||||
query_filter=qf_string,
|
||||
order_by="random",
|
||||
pagination_seed=self.repo._random_seed(),
|
||||
)
|
||||
)
|
||||
return recipes_data.items
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .about import (
|
||||
AdminAboutInfo,
|
||||
AppInfo,
|
||||
AppStartupInfo,
|
||||
AppStatistics,
|
||||
AppTheme,
|
||||
CheckAppConfig,
|
||||
)
|
||||
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig
|
||||
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
|
||||
from .debug import DebugResponse
|
||||
from .email import EmailReady, EmailSuccess, EmailTest
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict, Field, field_validator
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models.recipe import RecipeModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
|
||||
from mealie.schema.recipe.recipe import RecipeSummary
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
from ...db.models.household import CookBook
|
||||
from ..recipe.recipe_category import CategoryBase, TagBase
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class CreateCookBook(MealieModel):
|
||||
@@ -19,12 +20,7 @@ class CreateCookBook(MealieModel):
|
||||
slug: Annotated[str | None, Field(validate_default=True)] = None
|
||||
position: int = 1
|
||||
public: Annotated[bool, Field(validate_default=True)] = False
|
||||
categories: list[CategoryBase] = []
|
||||
tags: list[TagBase] = []
|
||||
tools: list[RecipeTool] = []
|
||||
require_all_categories: bool = True
|
||||
require_all_tags: bool = True
|
||||
require_all_tools: bool = True
|
||||
query_filter_string: str = ""
|
||||
|
||||
@field_validator("public", mode="before")
|
||||
def validate_public(public: bool | None) -> bool:
|
||||
@@ -42,6 +38,19 @@ class CreateCookBook(MealieModel):
|
||||
|
||||
return name
|
||||
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# The query filter builder does additional validations while building the
|
||||
# database query, so we make sure constructing the query is successful
|
||||
builder = QueryFilterBuilder(value)
|
||||
|
||||
try:
|
||||
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||
except Exception as e:
|
||||
raise ValueError("Invalid query filter string") from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class SaveCookBook(CreateCookBook):
|
||||
group_id: UUID4
|
||||
@@ -53,14 +62,24 @@ class UpdateCookBook(SaveCookBook):
|
||||
|
||||
|
||||
class ReadCookBook(UpdateCookBook):
|
||||
group_id: UUID4
|
||||
household_id: UUID4
|
||||
categories: list[CategoryBase] = []
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(CookBook.categories), joinedload(CookBook.tags), joinedload(CookBook.tools)]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# Skip validation since we are not updating the query filter string
|
||||
return value
|
||||
|
||||
@field_validator("query_filter", mode="before")
|
||||
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||
try:
|
||||
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||
builder = QueryFilterBuilder(query_filter_string)
|
||||
return builder.as_json_model()
|
||||
except Exception:
|
||||
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||
return QueryFilterJSON()
|
||||
|
||||
|
||||
class CookBookPagination(PaginationBase):
|
||||
|
||||
@@ -8,18 +8,7 @@ from .new_meal import (
|
||||
SavePlanEntry,
|
||||
UpdatePlanEntry,
|
||||
)
|
||||
from .plan_rules import (
|
||||
BasePlanRuleFilter,
|
||||
PlanCategory,
|
||||
PlanHousehold,
|
||||
PlanRulesCreate,
|
||||
PlanRulesDay,
|
||||
PlanRulesOut,
|
||||
PlanRulesPagination,
|
||||
PlanRulesSave,
|
||||
PlanRulesType,
|
||||
PlanTag,
|
||||
)
|
||||
from .plan_rules import PlanRulesCreate, PlanRulesDay, PlanRulesOut, PlanRulesPagination, PlanRulesSave, PlanRulesType
|
||||
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
||||
|
||||
__all__ = [
|
||||
@@ -33,14 +22,10 @@ __all__ = [
|
||||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"BasePlanRuleFilter",
|
||||
"PlanCategory",
|
||||
"PlanHousehold",
|
||||
"PlanRulesCreate",
|
||||
"PlanRulesDay",
|
||||
"PlanRulesOut",
|
||||
"PlanRulesPagination",
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"PlanTag",
|
||||
]
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||
|
||||
from mealie.db.models.household import GroupMealPlanRules, Household
|
||||
from mealie.db.models.recipe import Category, Tag
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models.recipe import RecipeModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
|
||||
class BasePlanRuleFilter(MealieModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class PlanCategory(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PlanTag(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PlanHousehold(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class PlanRulesDay(str, Enum):
|
||||
@@ -59,9 +44,20 @@ class PlanRulesType(str, Enum):
|
||||
class PlanRulesCreate(MealieModel):
|
||||
day: PlanRulesDay = PlanRulesDay.unset
|
||||
entry_type: PlanRulesType = PlanRulesType.unset
|
||||
categories: list[PlanCategory] = []
|
||||
tags: list[PlanTag] = []
|
||||
households: list[PlanHousehold] = []
|
||||
query_filter_string: str = ""
|
||||
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(cls, value: str) -> str:
|
||||
# The query filter builder does additional validations while building the
|
||||
# database query, so we make sure constructing the query is successful
|
||||
builder = QueryFilterBuilder(value)
|
||||
|
||||
try:
|
||||
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||
except Exception as e:
|
||||
raise ValueError("Invalid query filter string") from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class PlanRulesSave(PlanRulesCreate):
|
||||
@@ -71,27 +67,24 @@ class PlanRulesSave(PlanRulesCreate):
|
||||
|
||||
class PlanRulesOut(PlanRulesSave):
|
||||
id: UUID4
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(GroupMealPlanRules.categories).load_only(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.tags).load_only(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.households).load_only(
|
||||
Household.id,
|
||||
Household.name,
|
||||
Household.slug,
|
||||
),
|
||||
]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# Skip validation since we are not updating the query filter string
|
||||
return value
|
||||
|
||||
@field_validator("query_filter", mode="before")
|
||||
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||
try:
|
||||
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||
builder = QueryFilterBuilder(query_filter_string)
|
||||
return builder.as_json_model()
|
||||
except Exception:
|
||||
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||
return QueryFilterJSON()
|
||||
|
||||
|
||||
class PlanRulesPagination(PaginationBase):
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
|
||||
from .query_filter import LogicalOperator, QueryFilter, QueryFilterComponent, RelationalKeyword, RelationalOperator
|
||||
from .query_filter import (
|
||||
LogicalOperator,
|
||||
QueryFilterBuilder,
|
||||
QueryFilterBuilderComponent,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
)
|
||||
from .query_search import SearchFilter
|
||||
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
|
||||
from .validation import ValidationResponse
|
||||
|
||||
__all__ = [
|
||||
"LogicalOperator",
|
||||
"QueryFilter",
|
||||
"QueryFilterComponent",
|
||||
"QueryFilterBuilder",
|
||||
"QueryFilterBuilderComponent",
|
||||
"QueryFilterJSON",
|
||||
"QueryFilterJSONPart",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"ValidationResponse",
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.sql import sqltypes
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
|
||||
Model = TypeVar("Model", bound=SqlAlchemyBase)
|
||||
|
||||
@@ -104,7 +105,21 @@ class LogicalOperator(Enum):
|
||||
OR = "OR"
|
||||
|
||||
|
||||
class QueryFilterComponent:
|
||||
class QueryFilterJSONPart(MealieModel):
|
||||
left_parenthesis: str | None = None
|
||||
right_parenthesis: str | None = None
|
||||
logical_operator: LogicalOperator | None = None
|
||||
|
||||
attribute_name: str | None = None
|
||||
relational_operator: RelationalKeyword | RelationalOperator | None = None
|
||||
value: str | list[str] | None = None
|
||||
|
||||
|
||||
class QueryFilterJSON(MealieModel):
|
||||
parts: list[QueryFilterJSONPart] = []
|
||||
|
||||
|
||||
class QueryFilterBuilderComponent:
|
||||
"""A single relational statement"""
|
||||
|
||||
@staticmethod
|
||||
@@ -135,7 +150,7 @@ class QueryFilterComponent:
|
||||
] and not isinstance(value, list):
|
||||
raise ValueError(
|
||||
f"invalid query string: {relationship.value} must be given a list of values"
|
||||
f"enclosed by {QueryFilter.l_list_sep} and {QueryFilter.r_list_sep}"
|
||||
f"enclosed by {QueryFilterBuilder.l_list_sep} and {QueryFilterBuilder.r_list_sep}"
|
||||
)
|
||||
|
||||
if relationship is RelationalKeyword.IS or relationship is RelationalKeyword.IS_NOT:
|
||||
@@ -193,8 +208,18 @@ class QueryFilterComponent:
|
||||
|
||||
return sanitized_values if isinstance(self.value, list) else sanitized_values[0]
|
||||
|
||||
def as_json_model(self) -> QueryFilterJSONPart:
|
||||
return QueryFilterJSONPart(
|
||||
left_parenthesis=None,
|
||||
right_parenthesis=None,
|
||||
logical_operator=None,
|
||||
attribute_name=self.attribute_name,
|
||||
relational_operator=self.relationship,
|
||||
value=self.value,
|
||||
)
|
||||
|
||||
class QueryFilter:
|
||||
|
||||
class QueryFilterBuilder:
|
||||
l_group_sep: str = "("
|
||||
r_group_sep: str = ")"
|
||||
group_seps: set[str] = {l_group_sep, r_group_sep}
|
||||
@@ -205,13 +230,15 @@ class QueryFilter:
|
||||
|
||||
def __init__(self, filter_string: str) -> None:
|
||||
# parse filter string
|
||||
components = QueryFilter._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilter._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilter.l_group_sep) != base_components.count(QueryFilter.r_group_sep):
|
||||
components = QueryFilterBuilder._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilterBuilder._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilterBuilder.l_group_sep) != base_components.count(
|
||||
QueryFilterBuilder.r_group_sep
|
||||
):
|
||||
raise ValueError("invalid query string: parenthesis are unbalanced")
|
||||
|
||||
# parse base components into a filter group
|
||||
self.filter_components = QueryFilter._parse_base_components_into_filter_components(base_components)
|
||||
self.filter_components = QueryFilterBuilder._parse_base_components_into_filter_components(base_components)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
joined = " ".join(
|
||||
@@ -308,7 +335,7 @@ class QueryFilter:
|
||||
attr_model_map: dict[int, Any] = {}
|
||||
model_attr: InstrumentedAttribute
|
||||
for i, component in enumerate(self.filter_components):
|
||||
if not isinstance(component, QueryFilterComponent):
|
||||
if not isinstance(component, QueryFilterBuilderComponent):
|
||||
continue
|
||||
|
||||
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
|
||||
@@ -337,7 +364,7 @@ class QueryFilter:
|
||||
logical_operator_stack.append(component)
|
||||
|
||||
else:
|
||||
component = cast(QueryFilterComponent, component)
|
||||
component = cast(QueryFilterBuilderComponent, component)
|
||||
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
||||
|
||||
# Keywords
|
||||
@@ -395,7 +422,7 @@ class QueryFilter:
|
||||
subcomponents = []
|
||||
for component in components:
|
||||
# don't parse components comprised of only a separator
|
||||
if component in QueryFilter.group_seps:
|
||||
if component in QueryFilterBuilder.group_seps:
|
||||
subcomponents.append(component)
|
||||
continue
|
||||
|
||||
@@ -406,7 +433,7 @@ class QueryFilter:
|
||||
if c == '"':
|
||||
in_quotes = not in_quotes
|
||||
|
||||
if c in QueryFilter.group_seps and not in_quotes:
|
||||
if c in QueryFilterBuilder.group_seps and not in_quotes:
|
||||
if new_component:
|
||||
subcomponents.append(new_component)
|
||||
|
||||
@@ -437,17 +464,17 @@ class QueryFilter:
|
||||
list_value_components = []
|
||||
for component in components:
|
||||
# parse out lists as their own singular sub component
|
||||
subcomponents = component.split(QueryFilter.l_list_sep)
|
||||
subcomponents = component.split(QueryFilterBuilder.l_list_sep)
|
||||
for i, subcomponent in enumerate(subcomponents):
|
||||
if not i:
|
||||
continue
|
||||
|
||||
for j, list_value_string in enumerate(subcomponent.split(QueryFilter.r_list_sep)):
|
||||
for j, list_value_string in enumerate(subcomponent.split(QueryFilterBuilder.r_list_sep)):
|
||||
if j % 2:
|
||||
continue
|
||||
|
||||
list_value_components.append(
|
||||
[val.strip() for val in list_value_string.split(QueryFilter.list_item_sep)]
|
||||
[val.strip() for val in list_value_string.split(QueryFilterBuilder.list_item_sep)]
|
||||
)
|
||||
|
||||
quote_offset = 0
|
||||
@@ -455,16 +482,16 @@ class QueryFilter:
|
||||
for i, subcomponent in enumerate(subcomponents):
|
||||
# we are in a list subcomponent, which is already handled
|
||||
if in_list:
|
||||
if QueryFilter.r_list_sep in subcomponent:
|
||||
if QueryFilterBuilder.r_list_sep in subcomponent:
|
||||
# filter out the remainder of the list subcomponent and continue parsing
|
||||
base_components.append(list_value_components.pop(0))
|
||||
subcomponent = subcomponent.split(QueryFilter.r_list_sep, maxsplit=1)[-1].strip()
|
||||
subcomponent = subcomponent.split(QueryFilterBuilder.r_list_sep, maxsplit=1)[-1].strip()
|
||||
in_list = False
|
||||
else:
|
||||
continue
|
||||
|
||||
# don't parse components comprised of only a separator
|
||||
if subcomponent in QueryFilter.group_seps:
|
||||
if subcomponent in QueryFilterBuilder.group_seps:
|
||||
quote_offset += 1
|
||||
base_components.append(subcomponent)
|
||||
continue
|
||||
@@ -479,8 +506,8 @@ class QueryFilter:
|
||||
continue
|
||||
|
||||
# continue parsing this subcomponent up to the list, then skip over subsequent subcomponents
|
||||
if not in_list and QueryFilter.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilter.l_list_sep, maxsplit=1)
|
||||
if not in_list and QueryFilterBuilder.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilterBuilder.l_list_sep, maxsplit=1)
|
||||
subcomponent = subcomponent.strip()
|
||||
subcomponents.insert(i + 1, _new_sub_component)
|
||||
quote_offset += 1
|
||||
@@ -516,19 +543,19 @@ class QueryFilter:
|
||||
@staticmethod
|
||||
def _parse_base_components_into_filter_components(
|
||||
base_components: list[str | list[str]],
|
||||
) -> list[str | QueryFilterComponent | LogicalOperator]:
|
||||
) -> list[str | QueryFilterBuilderComponent | LogicalOperator]:
|
||||
"""Walk through base components and construct filter collections"""
|
||||
relational_keywords = [kw.value for kw in RelationalKeyword]
|
||||
relational_operators = [op.value for op in RelationalOperator]
|
||||
logical_operators = [op.value for op in LogicalOperator]
|
||||
|
||||
# parse QueryFilterComponents and logical operators
|
||||
components: list[str | QueryFilterComponent | LogicalOperator] = []
|
||||
components: list[str | QueryFilterBuilderComponent | LogicalOperator] = []
|
||||
for i, base_component in enumerate(base_components):
|
||||
if isinstance(base_component, list):
|
||||
continue
|
||||
|
||||
if base_component in QueryFilter.group_seps:
|
||||
if base_component in QueryFilterBuilder.group_seps:
|
||||
components.append(base_component)
|
||||
|
||||
elif base_component in relational_keywords or base_component in relational_operators:
|
||||
@@ -539,7 +566,7 @@ class QueryFilter:
|
||||
relationship = RelationalOperator(base_components[i])
|
||||
|
||||
components.append(
|
||||
QueryFilterComponent(
|
||||
QueryFilterBuilderComponent(
|
||||
attribute_name=base_components[i - 1], # type: ignore
|
||||
relationship=relationship,
|
||||
value=base_components[i + 1],
|
||||
@@ -550,3 +577,47 @@ class QueryFilter:
|
||||
components.append(LogicalOperator(base_component.upper()))
|
||||
|
||||
return components
|
||||
|
||||
def as_json_model(self) -> QueryFilterJSON:
|
||||
parts: list[QueryFilterJSONPart] = []
|
||||
|
||||
current_part: QueryFilterJSONPart | None = None
|
||||
left_parens: list[str] = []
|
||||
right_parens: list[str] = []
|
||||
last_logical_operator: LogicalOperator | None = None
|
||||
|
||||
def add_part():
|
||||
nonlocal current_part, left_parens, right_parens, last_logical_operator
|
||||
if not current_part:
|
||||
return
|
||||
|
||||
current_part.left_parenthesis = "".join(left_parens) or None
|
||||
current_part.right_parenthesis = "".join(right_parens) or None
|
||||
current_part.logical_operator = last_logical_operator
|
||||
|
||||
parts.append(current_part)
|
||||
current_part = None
|
||||
left_parens.clear()
|
||||
right_parens.clear()
|
||||
last_logical_operator = None
|
||||
|
||||
for component in self.filter_components:
|
||||
if isinstance(component, QueryFilterBuilderComponent):
|
||||
if current_part:
|
||||
add_part()
|
||||
current_part = component.as_json_model()
|
||||
|
||||
elif isinstance(component, LogicalOperator):
|
||||
if current_part:
|
||||
add_part()
|
||||
last_logical_operator = component
|
||||
|
||||
elif isinstance(component, str):
|
||||
if component == QueryFilterBuilder.l_group_sep:
|
||||
left_parens.append(component)
|
||||
elif component == QueryFilterBuilder.r_group_sep:
|
||||
right_parens.append(component)
|
||||
|
||||
# add last part, if any
|
||||
add_part()
|
||||
return QueryFilterJSON(parts=parts)
|
||||
|
||||
@@ -37,18 +37,18 @@ from .user_passwords import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CreateUserRegistration",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"ForgotPassword",
|
||||
"PasswordResetToken",
|
||||
"PrivatePasswordResetToken",
|
||||
"ResetPassword",
|
||||
"SavePasswordResetToken",
|
||||
"ValidateResetToken",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"CreateUserRegistration",
|
||||
"ChangePassword",
|
||||
"CreateToken",
|
||||
"DeleteTokenResponse",
|
||||
|
||||
Reference in New Issue
Block a user