mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-07 16:33:10 -05:00
* feat: server side search API (#2112) * refactor repository_recipes filter building * add food filter to recipe repository page_all * fix query type annotations * working search * add tests and make sure title matches are ordered correctly * remove instruction matching again * fix formatting and small issues * fix another linting error * make search test no rely on actual words * fix failing postgres compiled query * revise incorrectly ordered migration * automatically extract latest migration version * test migration orderes * run type generators * new search function * wip: new search page * sortable field options * fix virtual scroll issue * fix search casing bug * finalize search filters/sorts * remove old composable * fix type errors --------- Co-authored-by: Sören <fleshgolem@gmx.net>
This commit is contained in:
@@ -4,7 +4,7 @@ from uuid import UUID
|
||||
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy import Select, and_, desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
@@ -20,13 +20,14 @@ from mealie.schema.recipe.recipe import (
|
||||
RecipeCategory,
|
||||
RecipePagination,
|
||||
RecipeSummary,
|
||||
RecipeSummaryWithIngredients,
|
||||
RecipeTag,
|
||||
RecipeTool,
|
||||
)
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from ..schema._mealie.mealie_model import extract_uuids
|
||||
from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
@@ -134,16 +135,59 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchemyBase]) -> list[UUID] | None:
|
||||
if not items:
|
||||
return None
|
||||
ids: list[UUID] = []
|
||||
slugs: list[str] = []
|
||||
|
||||
for i in items:
|
||||
if isinstance(i, UUID):
|
||||
ids.append(i)
|
||||
else:
|
||||
slugs.append(i)
|
||||
additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
return ids + additional_ids
|
||||
|
||||
def _add_search_to_query(self, query: Select, search: str) -> Select:
|
||||
# I would prefer to just do this in the recipe_ingredient.any part of the main query, but it turns out
|
||||
# that at least sqlite wont use indexes for that correctly anymore and takes a big hit, so prefiltering it is
|
||||
ingredient_ids = (
|
||||
self.session.execute(
|
||||
select(RecipeIngredient.id).filter(
|
||||
or_(RecipeIngredient.note.ilike(f"%{search}%"), RecipeIngredient.original_text.ilike(f"%{search}%"))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
q = query.filter(
|
||||
or_(
|
||||
RecipeModel.name.ilike(f"%{search}%"),
|
||||
RecipeModel.description.ilike(f"%{search}%"),
|
||||
RecipeModel.recipe_ingredient.any(RecipeIngredient.id.in_(ingredient_ids)),
|
||||
)
|
||||
).order_by(desc(RecipeModel.name.ilike(f"%{search}%")))
|
||||
return q
|
||||
|
||||
def page_all(
|
||||
self,
|
||||
pagination: PaginationQuery,
|
||||
override=None,
|
||||
load_food=False,
|
||||
cookbook: ReadCookBook | None = None,
|
||||
categories: list[UUID4 | str] | None = None,
|
||||
tags: list[UUID4 | str] | None = None,
|
||||
tools: list[UUID4 | str] | None = None,
|
||||
foods: list[UUID4 | str] | None = None,
|
||||
require_all_categories=True,
|
||||
require_all_tags=True,
|
||||
require_all_tools=True,
|
||||
require_all_foods=True,
|
||||
search: str | None = None,
|
||||
) -> RecipePagination:
|
||||
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
|
||||
pagination_result = pagination.copy()
|
||||
q = select(self.model)
|
||||
|
||||
args = [
|
||||
@@ -152,57 +196,41 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
joinedload(RecipeModel.tools),
|
||||
]
|
||||
|
||||
item_class: type[RecipeSummary | RecipeSummaryWithIngredients]
|
||||
|
||||
if load_food:
|
||||
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
|
||||
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.unit)))
|
||||
item_class = RecipeSummaryWithIngredients
|
||||
else:
|
||||
item_class = RecipeSummary
|
||||
|
||||
q = q.options(*args)
|
||||
|
||||
fltr = self._filter_builder()
|
||||
q = q.filter_by(**fltr)
|
||||
|
||||
if cookbook:
|
||||
cb_filters = self._category_tag_filters(
|
||||
cookbook.categories,
|
||||
cookbook.tags,
|
||||
cookbook.tools,
|
||||
cookbook.require_all_categories,
|
||||
cookbook.require_all_tags,
|
||||
cookbook.require_all_tools,
|
||||
cb_filters = self._build_recipe_filter(
|
||||
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)
|
||||
else:
|
||||
category_ids = self._uuids_for_items(categories, Category)
|
||||
tag_ids = self._uuids_for_items(tags, Tag)
|
||||
tool_ids = self._uuids_for_items(tools, Tool)
|
||||
filters = self._build_recipe_filter(
|
||||
categories=category_ids,
|
||||
tags=tag_ids,
|
||||
tools=tool_ids,
|
||||
foods=foods,
|
||||
require_all_categories=require_all_categories,
|
||||
require_all_tags=require_all_tags,
|
||||
require_all_tools=require_all_tools,
|
||||
require_all_foods=require_all_foods,
|
||||
)
|
||||
q = q.filter(*filters)
|
||||
if search:
|
||||
q = self._add_search_to_query(q, search)
|
||||
|
||||
if categories:
|
||||
for category in categories:
|
||||
if isinstance(category, UUID):
|
||||
q = q.filter(RecipeModel.recipe_category.any(Category.id == category))
|
||||
|
||||
else:
|
||||
q = q.filter(RecipeModel.recipe_category.any(Category.slug == category))
|
||||
|
||||
if tags:
|
||||
for tag in tags:
|
||||
if isinstance(tag, UUID):
|
||||
q = q.filter(RecipeModel.tags.any(Tag.id == tag))
|
||||
|
||||
else:
|
||||
q = q.filter(RecipeModel.tags.any(Tag.slug == tag))
|
||||
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if isinstance(tool, UUID):
|
||||
q = q.filter(RecipeModel.tools.any(Tool.id == tool))
|
||||
|
||||
else:
|
||||
q = q.filter(RecipeModel.tools.any(Tool.slug == tool))
|
||||
|
||||
q, count, total_pages = self.add_pagination_to_query(q, pagination)
|
||||
q, count, total_pages = self.add_pagination_to_query(q, pagination_result)
|
||||
|
||||
try:
|
||||
data = self.session.execute(q).scalars().unique().all()
|
||||
@@ -211,10 +239,10 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
self.session.rollback()
|
||||
raise e
|
||||
|
||||
items = [item_class.from_orm(item) for item in data]
|
||||
items = [RecipeSummary.from_orm(item) for item in data]
|
||||
return RecipePagination(
|
||||
page=pagination.page,
|
||||
per_page=pagination.per_page,
|
||||
page=pagination_result.page,
|
||||
per_page=pagination_result.per_page,
|
||||
total=count,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
@@ -233,41 +261,46 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
)
|
||||
return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()]
|
||||
|
||||
def _category_tag_filters(
|
||||
def _build_recipe_filter(
|
||||
self,
|
||||
categories: list[CategoryBase] | None = None,
|
||||
tags: list[TagBase] | None = None,
|
||||
tools: list[RecipeTool] | None = None,
|
||||
categories: list[UUID4] | None = None,
|
||||
tags: list[UUID4] | None = None,
|
||||
tools: list[UUID4] | None = None,
|
||||
foods: list[UUID4] | None = None,
|
||||
require_all_categories: bool = True,
|
||||
require_all_tags: bool = True,
|
||||
require_all_tools: bool = True,
|
||||
require_all_foods: bool = True,
|
||||
) -> list:
|
||||
fltr = [
|
||||
RecipeModel.group_id == self.group_id,
|
||||
]
|
||||
if self.group_id:
|
||||
fltr = [
|
||||
RecipeModel.group_id == self.group_id,
|
||||
]
|
||||
else:
|
||||
fltr = []
|
||||
|
||||
if categories:
|
||||
cat_ids = [x.id for x in categories]
|
||||
if require_all_categories:
|
||||
fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in cat_ids)
|
||||
fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in categories)
|
||||
else:
|
||||
fltr.append(RecipeModel.recipe_category.any(Category.id.in_(cat_ids)))
|
||||
fltr.append(RecipeModel.recipe_category.any(Category.id.in_(categories)))
|
||||
|
||||
if tags:
|
||||
tag_ids = [x.id for x in tags]
|
||||
if require_all_tags:
|
||||
fltr.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids)
|
||||
fltr.extend(RecipeModel.tags.any(Tag.id == tag_id) for tag_id in tags)
|
||||
else:
|
||||
fltr.append(RecipeModel.tags.any(Tag.id.in_(tag_ids)))
|
||||
fltr.append(RecipeModel.tags.any(Tag.id.in_(tags)))
|
||||
|
||||
if tools:
|
||||
tool_ids = [x.id for x in tools]
|
||||
|
||||
if require_all_tools:
|
||||
fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tool_ids)
|
||||
fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tools)
|
||||
else:
|
||||
fltr.append(RecipeModel.tools.any(Tool.id.in_(tool_ids)))
|
||||
|
||||
fltr.append(RecipeModel.tools.any(Tool.id.in_(tools)))
|
||||
if foods:
|
||||
if require_all_foods:
|
||||
fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id == food) for food in foods)
|
||||
else:
|
||||
fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id.in_(foods)))
|
||||
return fltr
|
||||
|
||||
def by_category_and_tags(
|
||||
@@ -279,8 +312,13 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
require_all_tags: bool = True,
|
||||
require_all_tools: bool = True,
|
||||
) -> list[Recipe]:
|
||||
fltr = self._category_tag_filters(
|
||||
categories, tags, tools, require_all_categories, require_all_tags, require_all_tools
|
||||
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 = select(RecipeModel).filter(*fltr)
|
||||
return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
@@ -297,7 +335,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
# See Also:
|
||||
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
|
||||
|
||||
filters = self._category_tag_filters(categories, tags) # type: ignore
|
||||
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
|
||||
stmt = (
|
||||
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user