feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)

This commit is contained in:
Michael Genson
2024-10-17 10:35:39 -05:00
committed by GitHub
parent 2a9a6fa5e6
commit b8e62ab8dd
47 changed files with 2043 additions and 440 deletions

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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",
]

View File

@@ -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):

View File

@@ -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",

View File

@@ -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)

View File

@@ -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",