mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-15 04:13:11 -05:00
feat: Query relative dates (#6984)
This commit is contained in:
@@ -24,8 +24,8 @@ from mealie.schema.response.pagination import (
|
||||
PaginationQuery,
|
||||
RequestQuery,
|
||||
)
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||
from mealie.schema.response.query_search import SearchFilter
|
||||
from mealie.services.query_filter.builder import QueryFilterBuilder
|
||||
|
||||
from ._utils import NOT_SET, NotSet
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from mealie.schema.recipe.recipe_ingredient import IngredientFood
|
||||
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolOut
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||
from mealie.services.query_filter.builder import QueryFilterBuilder
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from .repository_generic import HouseholdRepositoryGeneric
|
||||
|
||||
@@ -11,7 +11,7 @@ from mealie.db.models.household.cookbook import CookBook
|
||||
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
|
||||
from mealie.services.query_filter.builder import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
from mealie.services.query_filter.builder import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .general import OpenAIText
|
||||
from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
|
||||
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
|
||||
|
||||
@@ -9,4 +10,5 @@ __all__ = [
|
||||
"OpenAIRecipeIngredient",
|
||||
"OpenAIRecipeInstruction",
|
||||
"OpenAIRecipeNotes",
|
||||
"OpenAIText",
|
||||
]
|
||||
|
||||
@@ -7,15 +7,6 @@ from .pagination import (
|
||||
RecipeSearchQuery,
|
||||
RequestQuery,
|
||||
)
|
||||
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
|
||||
@@ -25,13 +16,6 @@ __all__ = [
|
||||
"FileTokenResponse",
|
||||
"SuccessResponse",
|
||||
"SearchFilter",
|
||||
"LogicalOperator",
|
||||
"QueryFilterBuilder",
|
||||
"QueryFilterBuilderComponent",
|
||||
"QueryFilterJSON",
|
||||
"QueryFilterJSONPart",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"OrderByNullPosition",
|
||||
"OrderDirection",
|
||||
"PaginationBase",
|
||||
|
||||
0
mealie/services/query_filter/__init__.py
Normal file
0
mealie/services/query_filter/__init__.py
Normal file
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
@@ -19,88 +18,8 @@ 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
|
||||
|
||||
|
||||
class RelationalKeyword(Enum):
|
||||
IS = "IS"
|
||||
IS_NOT = "IS NOT"
|
||||
IN = "IN"
|
||||
NOT_IN = "NOT IN"
|
||||
CONTAINS_ALL = "CONTAINS ALL"
|
||||
LIKE = "LIKE"
|
||||
NOT_LIKE = "NOT LIKE"
|
||||
|
||||
@classmethod
|
||||
def parse_component(cls, component: str) -> list[str] | None:
|
||||
"""
|
||||
Try to parse a component using a relational keyword
|
||||
|
||||
If no matching keyword is found, returns None
|
||||
"""
|
||||
|
||||
# extract the attribute name from the component
|
||||
parsed_component = component.split(maxsplit=1)
|
||||
if len(parsed_component) < 2:
|
||||
return None
|
||||
|
||||
# assume the component has already filtered out the value and try to match a keyword
|
||||
# if we try to filter out the value without checking first, keywords with spaces won't parse correctly
|
||||
possible_keyword = parsed_component[1].strip().lower()
|
||||
for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
|
||||
if rel_kw.lower() != possible_keyword:
|
||||
continue
|
||||
|
||||
parsed_component[1] = rel_kw
|
||||
return parsed_component
|
||||
|
||||
# there was no match, so the component may still have the value in it
|
||||
try:
|
||||
_possible_keyword, _value = parsed_component[-1].rsplit(maxsplit=1)
|
||||
parsed_component = [parsed_component[0], _possible_keyword, _value]
|
||||
except ValueError:
|
||||
# the component has no value to filter out
|
||||
return None
|
||||
|
||||
possible_keyword = parsed_component[1].strip().lower()
|
||||
for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
|
||||
if rel_kw.lower() != possible_keyword:
|
||||
continue
|
||||
|
||||
parsed_component[1] = rel_kw
|
||||
return parsed_component
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class RelationalOperator(Enum):
|
||||
EQ = "="
|
||||
NOTEQ = "<>"
|
||||
GT = ">"
|
||||
LT = "<"
|
||||
GTE = ">="
|
||||
LTE = "<="
|
||||
|
||||
@classmethod
|
||||
def parse_component(cls, component: str) -> list[str] | None:
|
||||
"""
|
||||
Try to parse a component using a relational operator
|
||||
|
||||
If no matching operator is found, returns None
|
||||
"""
|
||||
|
||||
for rel_op in sorted([operator.value for operator in cls], key=len, reverse=True):
|
||||
if rel_op not in component:
|
||||
continue
|
||||
|
||||
parsed_component = [base_component.strip() for base_component in component.split(rel_op) if base_component]
|
||||
parsed_component.insert(1, rel_op)
|
||||
return parsed_component
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class LogicalOperator(Enum):
|
||||
AND = "AND"
|
||||
OR = "OR"
|
||||
from .keywords import PlaceholderKeyword, RelationalKeyword
|
||||
from .operators import LogicalOperator, RelationalOperator
|
||||
|
||||
|
||||
class QueryFilterJSONPart(MealieModel):
|
||||
@@ -161,6 +80,9 @@ class QueryFilterBuilderComponent:
|
||||
else:
|
||||
self.value = value
|
||||
|
||||
# process placeholder keywords
|
||||
self.value = PlaceholderKeyword.parse_value(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"[{self.attribute_name} {self.relationship.value} {self.value}]"
|
||||
|
||||
154
mealie/services/query_filter/keywords.py
Normal file
154
mealie/services/query_filter/keywords.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import overload
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class PlaceholderKeyword(Enum):
|
||||
NOW = "$NOW"
|
||||
|
||||
@classmethod
|
||||
def _parse_now(cls, value: str) -> str:
|
||||
"""
|
||||
Parses a NOW value, with optional math using an int or float.
|
||||
|
||||
Operation:
|
||||
- '+'
|
||||
- '-'
|
||||
|
||||
Unit:
|
||||
- 'y' (year)
|
||||
- 'm' (month)
|
||||
- 'd' (day)
|
||||
- 'H' (hour)
|
||||
- 'M' (minute)
|
||||
- 'S' (second)
|
||||
|
||||
Examples:
|
||||
- '$NOW'
|
||||
- '$NOW+30d'
|
||||
- '$NOW-5M'
|
||||
"""
|
||||
|
||||
if not value.startswith(cls.NOW.value):
|
||||
return value
|
||||
|
||||
now = datetime.now(tz=None) # noqa: DTZ005
|
||||
remainder = value[len(cls.NOW.value) :]
|
||||
|
||||
if remainder:
|
||||
if len(remainder) < 3:
|
||||
raise ValueError(f"Invalid remainder in NOW string ({value})")
|
||||
|
||||
op = remainder[0]
|
||||
amount_str = remainder[1:-1]
|
||||
unit = remainder[-1]
|
||||
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid amount in NOW string ({value})") from e
|
||||
|
||||
if op == "-":
|
||||
amount = -amount
|
||||
elif op != "+":
|
||||
raise ValueError(f"Invalid operator in NOW string ({value})")
|
||||
|
||||
if unit == "y":
|
||||
delta = relativedelta(years=amount)
|
||||
elif unit == "m":
|
||||
delta = relativedelta(months=amount)
|
||||
elif unit == "d":
|
||||
delta = relativedelta(days=amount)
|
||||
elif unit == "H":
|
||||
delta = relativedelta(hours=amount)
|
||||
elif unit == "M":
|
||||
delta = relativedelta(minutes=amount)
|
||||
elif unit == "S":
|
||||
delta = relativedelta(seconds=amount)
|
||||
else:
|
||||
raise ValueError(f"Invalid time unit in NOW string ({value})")
|
||||
|
||||
dt = now + delta
|
||||
|
||||
else:
|
||||
dt = now
|
||||
|
||||
return dt.isoformat()
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def parse_value(cls, value: str) -> str: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def parse_value(cls, value: list[str]) -> list[str]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def parse_value(cls, value: None) -> None: ...
|
||||
|
||||
@classmethod
|
||||
def parse_value(cls, value: str | list[str] | None) -> str | list[str] | None:
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if isinstance(value, list):
|
||||
return [cls.parse_value(v) for v in value]
|
||||
|
||||
if value.startswith(PlaceholderKeyword.NOW.value):
|
||||
return cls._parse_now(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class RelationalKeyword(Enum):
|
||||
IS = "IS"
|
||||
IS_NOT = "IS NOT"
|
||||
IN = "IN"
|
||||
NOT_IN = "NOT IN"
|
||||
CONTAINS_ALL = "CONTAINS ALL"
|
||||
LIKE = "LIKE"
|
||||
NOT_LIKE = "NOT LIKE"
|
||||
|
||||
@classmethod
|
||||
def parse_component(cls, component: str) -> list[str] | None:
|
||||
"""
|
||||
Try to parse a component using a relational keyword
|
||||
|
||||
If no matching keyword is found, returns None
|
||||
"""
|
||||
|
||||
# extract the attribute name from the component
|
||||
parsed_component = component.split(maxsplit=1)
|
||||
if len(parsed_component) < 2:
|
||||
return None
|
||||
|
||||
# assume the component has already filtered out the value and try to match a keyword
|
||||
# if we try to filter out the value without checking first, keywords with spaces won't parse correctly
|
||||
possible_keyword = parsed_component[1].strip().lower()
|
||||
for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
|
||||
if rel_kw.lower() != possible_keyword:
|
||||
continue
|
||||
|
||||
parsed_component[1] = rel_kw
|
||||
return parsed_component
|
||||
|
||||
# there was no match, so the component may still have the value in it
|
||||
try:
|
||||
_possible_keyword, _value = parsed_component[-1].rsplit(maxsplit=1)
|
||||
parsed_component = [parsed_component[0], _possible_keyword, _value]
|
||||
except ValueError:
|
||||
# the component has no value to filter out
|
||||
return None
|
||||
|
||||
possible_keyword = parsed_component[1].strip().lower()
|
||||
for rel_kw in sorted([keyword.value for keyword in cls], key=len, reverse=True):
|
||||
if rel_kw.lower() != possible_keyword:
|
||||
continue
|
||||
|
||||
parsed_component[1] = rel_kw
|
||||
return parsed_component
|
||||
|
||||
return None
|
||||
33
mealie/services/query_filter/operators.py
Normal file
33
mealie/services/query_filter/operators.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogicalOperator(Enum):
|
||||
AND = "AND"
|
||||
OR = "OR"
|
||||
|
||||
|
||||
class RelationalOperator(Enum):
|
||||
EQ = "="
|
||||
NOTEQ = "<>"
|
||||
GT = ">"
|
||||
LT = "<"
|
||||
GTE = ">="
|
||||
LTE = "<="
|
||||
|
||||
@classmethod
|
||||
def parse_component(cls, component: str) -> list[str] | None:
|
||||
"""
|
||||
Try to parse a component using a relational operator
|
||||
|
||||
If no matching operator is found, returns None
|
||||
"""
|
||||
|
||||
for rel_op in sorted([operator.value for operator in cls], key=len, reverse=True):
|
||||
if rel_op not in component:
|
||||
continue
|
||||
|
||||
parsed_component = [base_component.strip() for base_component in component.split(rel_op) if base_component]
|
||||
parsed_component.insert(1, rel_op)
|
||||
return parsed_component
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user