mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-03 06:23:10 -05:00
feat: Query relative dates (#6984)
This commit is contained in:
@@ -89,6 +89,26 @@ This filter will find all recipes that don't start with the word "Test": <br>
|
||||
This filter will find all recipes that have particular slugs: <br>
|
||||
`slug IN ["pasta-fagioli", "delicious-ramen"]`
|
||||
|
||||
##### Placeholder Keywords
|
||||
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current time.
|
||||
|
||||
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
|
||||
`lastMade <= "$NOW-30d"`
|
||||
|
||||
Supported offsets operations include:
|
||||
- `-` for subtracting a time (i.e. in the past)
|
||||
- `+` for adding a time (i.e. in the future)
|
||||
|
||||
Supported offset intervals include:
|
||||
- `y` for years
|
||||
- `m` for months
|
||||
- `d` for days
|
||||
- `H` for hours
|
||||
- `M` for minutes
|
||||
- `S` for seconds
|
||||
|
||||
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
|
||||
|
||||
##### Nested Property filters
|
||||
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
|
||||
`user.username = "SousChef20220320"`
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
|
||||
@@ -319,7 +319,7 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface FieldLogicalOperator {
|
||||
label: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
import { ActivityKey } from "~/lib/api/types/activity";
|
||||
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface UserPrintPreferences {
|
||||
imagePosition: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface QueryFilterJSONPart {
|
||||
attributeName?: string | null;
|
||||
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||
value?: string | string[] | null;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface SaveCookBook {
|
||||
name: string;
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface QueryFilterJSONPart {
|
||||
attributeName?: string | null;
|
||||
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||
value?: string | string[] | null;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface PlanRulesSave {
|
||||
day?: PlanRulesDay;
|
||||
|
||||
@@ -40,3 +40,20 @@ export enum Organizer {
|
||||
Household = "households",
|
||||
User = "users",
|
||||
}
|
||||
|
||||
export type LogicalOperator = "AND" | "OR";
|
||||
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
}
|
||||
|
||||
export interface QueryFilterJSONPart {
|
||||
leftParenthesis?: string | null;
|
||||
rightParenthesis?: string | null;
|
||||
logicalOperator?: LogicalOperator | null;
|
||||
attributeName?: string | null;
|
||||
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||
value?: string | string[] | null;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface OpenAIIngredients {
|
||||
}
|
||||
export interface OpenAIRecipe {
|
||||
name: string;
|
||||
description: string | null;
|
||||
description?: string | null;
|
||||
recipe_yield?: string | null;
|
||||
total_time?: string | null;
|
||||
prep_time?: string | null;
|
||||
@@ -37,4 +37,7 @@ export interface OpenAIRecipeNotes {
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIText {
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIBase {}
|
||||
|
||||
@@ -502,13 +502,16 @@ export interface SaveIngredientUnit {
|
||||
}
|
||||
export interface ScrapeRecipe {
|
||||
includeTags?: boolean;
|
||||
includeCategories?: boolean;
|
||||
url: string;
|
||||
}
|
||||
export interface ScrapeRecipeBase {
|
||||
includeTags?: boolean;
|
||||
includeCategories?: boolean;
|
||||
}
|
||||
export interface ScrapeRecipeData {
|
||||
includeTags?: boolean;
|
||||
includeCategories?: boolean;
|
||||
data: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
|
||||
export type OrderByNullPosition = "first" | "last";
|
||||
export type OrderDirection = "asc" | "desc";
|
||||
export type LogicalOperator = "AND" | "OR";
|
||||
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
@@ -28,17 +25,6 @@ export interface PaginationQuery {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
}
|
||||
export interface QueryFilterJSONPart {
|
||||
leftParenthesis?: string | null;
|
||||
rightParenthesis?: string | null;
|
||||
logicalOperator?: LogicalOperator | null;
|
||||
attributeName?: string | null;
|
||||
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||
value?: string | string[] | null;
|
||||
}
|
||||
export interface RecipeSearchQuery {
|
||||
cookbook?: string | null;
|
||||
requireAllCategories?: boolean;
|
||||
|
||||
@@ -422,7 +422,7 @@ import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue";
|
||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { useRecipeFinderPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,7 @@ from random import randint
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi.testclient import TestClient
|
||||
from humps import camelize
|
||||
from pydantic import UUID4
|
||||
@@ -34,6 +35,7 @@ from mealie.schema.response.pagination import (
|
||||
PaginationQuery,
|
||||
)
|
||||
from mealie.schema.user.user import UserRatingUpdate
|
||||
from mealie.services.query_filter.builder import PlaceholderKeyword
|
||||
from mealie.services.seeder.seeder_service import SeederService
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_int, random_string
|
||||
@@ -1567,3 +1569,195 @@ def test_pagination_filter_by_custom_rating(api_client: TestClient, user_tuple:
|
||||
recipes_data = response.json()["items"]
|
||||
assert len(recipes_data) == 1
|
||||
assert recipes_data[0]["id"] == str(recipe_2.id)
|
||||
|
||||
|
||||
def test_parse_now_with_remainder_too_short():
|
||||
with pytest.raises(ValueError, match="Invalid remainder"):
|
||||
PlaceholderKeyword._parse_now("$NOW+d")
|
||||
|
||||
|
||||
def test_parse_now_without_arithmetic():
|
||||
result = PlaceholderKeyword._parse_now("$NOW")
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
|
||||
|
||||
def test_parse_now_passthrough_non_placeholder():
|
||||
test_string = "2024-01-15"
|
||||
result = PlaceholderKeyword._parse_now(test_string)
|
||||
assert result == test_string
|
||||
|
||||
|
||||
def test_parse_now_with_int_amount():
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now("$NOW+30d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is exactly 30 days from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + timedelta(days=30)
|
||||
expected_max = after + timedelta(days=30)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
def test_parse_now_with_single_digit_int():
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now("$NOW+1d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is exactly 1 day from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + timedelta(days=1)
|
||||
expected_max = after + timedelta(days=1)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"])
|
||||
def test_parse_now_with_invalid_amount(invalid_amount):
|
||||
with pytest.raises(ValueError, match="Invalid amount"):
|
||||
PlaceholderKeyword._parse_now(f"$NOW+{invalid_amount}d")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unit,offset_delta",
|
||||
[
|
||||
("y", relativedelta(years=5)),
|
||||
("m", relativedelta(months=5)),
|
||||
("d", timedelta(days=5)),
|
||||
("H", timedelta(hours=5)),
|
||||
("M", timedelta(minutes=5)),
|
||||
("S", timedelta(seconds=5)),
|
||||
],
|
||||
)
|
||||
def test_parse_now_with_valid_units(unit, offset_delta):
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now(f"$NOW+5{unit}")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is correct from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + offset_delta
|
||||
expected_max = after + offset_delta
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
def test_parse_now_with_invalid_unit():
|
||||
with pytest.raises(ValueError, match="Invalid time unit"):
|
||||
PlaceholderKeyword._parse_now("$NOW+1x")
|
||||
|
||||
|
||||
def test_parse_now_with_missing_unit():
|
||||
with pytest.raises(ValueError, match="Invalid remainder"):
|
||||
PlaceholderKeyword._parse_now("$NOW+1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,expected_sign", [("+", 1), ("-", -1)])
|
||||
def test_parse_now_with_valid_operations(operation, expected_sign):
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now(f"$NOW{operation}5d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset direction is correct from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + (timedelta(days=5) * expected_sign)
|
||||
expected_max = after + (timedelta(days=5) * expected_sign)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"])
|
||||
def test_parse_now_with_invalid_operations(invalid_operation):
|
||||
with pytest.raises(ValueError, match="Invalid operator"):
|
||||
PlaceholderKeyword._parse_now(f"$NOW{invalid_operation}5d")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"placeholder,included_dates,excluded_dates",
|
||||
[
|
||||
pytest.param(
|
||||
"$NOW",
|
||||
["today", "tomorrow"],
|
||||
["yesterday"],
|
||||
id="now_current_day",
|
||||
),
|
||||
pytest.param(
|
||||
"$NOW+1d",
|
||||
["tomorrow", "day_after_tomorrow"],
|
||||
["yesterday", "today"],
|
||||
id="now_plus_one_day",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_e2e_parse_now_placeholder(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
placeholder: str,
|
||||
included_dates: list[str],
|
||||
excluded_dates: list[str],
|
||||
):
|
||||
"""E2E test for parsing $NOW and $NOW+Xd placeholders in datetime filters"""
|
||||
# Create recipes for testing
|
||||
recipe_yesterday = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_today = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_tomorrow = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_day_after = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
|
||||
date_map = {
|
||||
"yesterday": (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"today": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"tomorrow": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"day_after_tomorrow": (datetime.now(UTC) + timedelta(days=2)).strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
recipe_map = {
|
||||
"yesterday": recipe_yesterday,
|
||||
"today": recipe_today,
|
||||
"tomorrow": recipe_tomorrow,
|
||||
"day_after_tomorrow": recipe_day_after,
|
||||
}
|
||||
|
||||
for date_name, recipe in recipe_map.items():
|
||||
date_str = date_map[date_name]
|
||||
datetime_str = f"{date_str}T12:00:00Z"
|
||||
r = api_client.patch(
|
||||
api_routes.recipes_slug_last_made(recipe.slug),
|
||||
json={"timestamp": datetime_str},
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Query using placeholder
|
||||
params = {"page": 1, "perPage": -1, "queryFilter": f'lastMade >= "{placeholder}"'}
|
||||
response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipes_data = response.json()["items"]
|
||||
result_ids = {recipe["id"] for recipe in recipes_data}
|
||||
|
||||
# Verify included and excluded recipes
|
||||
for date_name in included_dates:
|
||||
recipe_id = str(recipe_map[date_name].id)
|
||||
assert recipe_id in result_ids, f"{date_name} should be included with {placeholder}"
|
||||
|
||||
for date_name in excluded_dates:
|
||||
recipe_id = str(recipe_map[date_name].id)
|
||||
assert recipe_id not in result_ids, f"{date_name} should be excluded with {placeholder}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from mealie.schema.response.query_filter import (
|
||||
from mealie.services.query_filter.builder import (
|
||||
LogicalOperator,
|
||||
QueryFilterBuilder,
|
||||
QueryFilterJSON,
|
||||
|
||||
Reference in New Issue
Block a user