feat: Query relative dates (#6984)

This commit is contained in:
Michael Genson
2026-02-01 21:36:46 -06:00
committed by GitHub
parent f6dbd1f1f1
commit 987c7209fc
24 changed files with 445 additions and 125 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View 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}]"

View 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

View 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