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

@@ -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> This filter will find all recipes that have particular slugs: <br>
`slug IN ["pasta-fagioli", "delicious-ramen"]` `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 ##### 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> 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"` `user.username = "SousChef20220320"`

View File

@@ -36,7 +36,7 @@
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated"; 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 { interface Props {
queryFilter?: QueryFilterJSON | null; queryFilter?: QueryFilterJSON | null;

View File

@@ -319,7 +319,7 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated"; 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 { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-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"; import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";

View File

@@ -1,5 +1,5 @@
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response"; import type { LogicalOperator, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
export interface FieldLogicalOperator { export interface FieldLogicalOperator {
label: string; label: string;

View File

@@ -1,7 +1,7 @@
import { useLocalStorage, useSessionStorage } from "@vueuse/core"; import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { ActivityKey } from "~/lib/api/types/activity"; import { ActivityKey } from "~/lib/api/types/activity";
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe"; 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 { export interface UserPrintPreferences {
imagePosition: string; imagePosition: string;

View File

@@ -44,6 +44,7 @@ export interface QueryFilterJSONPart {
attributeName?: string | null; attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null; relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null; value?: string | string[] | null;
[k: string]: unknown;
} }
export interface SaveCookBook { export interface SaveCookBook {
name: string; name: string;

View File

@@ -53,6 +53,7 @@ export interface QueryFilterJSONPart {
attributeName?: string | null; attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null; relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null; value?: string | string[] | null;
[k: string]: unknown;
} }
export interface PlanRulesSave { export interface PlanRulesSave {
day?: PlanRulesDay; day?: PlanRulesDay;

View File

@@ -40,3 +40,20 @@ export enum Organizer {
Household = "households", Household = "households",
User = "users", 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;
}

View File

@@ -16,7 +16,7 @@ export interface OpenAIIngredients {
} }
export interface OpenAIRecipe { export interface OpenAIRecipe {
name: string; name: string;
description: string | null; description?: string | null;
recipe_yield?: string | null; recipe_yield?: string | null;
total_time?: string | null; total_time?: string | null;
prep_time?: string | null; prep_time?: string | null;
@@ -37,4 +37,7 @@ export interface OpenAIRecipeNotes {
title?: string | null; title?: string | null;
text: string; text: string;
} }
export interface OpenAIText {
text: string;
}
export interface OpenAIBase {} export interface OpenAIBase {}

View File

@@ -502,13 +502,16 @@ export interface SaveIngredientUnit {
} }
export interface ScrapeRecipe { export interface ScrapeRecipe {
includeTags?: boolean; includeTags?: boolean;
includeCategories?: boolean;
url: string; url: string;
} }
export interface ScrapeRecipeBase { export interface ScrapeRecipeBase {
includeTags?: boolean; includeTags?: boolean;
includeCategories?: boolean;
} }
export interface ScrapeRecipeData { export interface ScrapeRecipeData {
includeTags?: boolean; includeTags?: boolean;
includeCategories?: boolean;
data: string; data: string;
url?: string | null; url?: string | null;
} }

View File

@@ -7,9 +7,6 @@
export type OrderByNullPosition = "first" | "last"; export type OrderByNullPosition = "first" | "last";
export type OrderDirection = "asc" | "desc"; 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 { export interface ErrorResponse {
message: string; message: string;
@@ -28,17 +25,6 @@ export interface PaginationQuery {
page?: number; page?: number;
perPage?: 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 { export interface RecipeSearchQuery {
cookbook?: string | null; cookbook?: string | null;
requireAllCategories?: boolean; requireAllCategories?: boolean;

View File

@@ -422,7 +422,7 @@ import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue"; import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue";
import SearchFilter from "~/components/Domain/SearchFilter.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 type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { useRecipeFinderPreferences } from "~/composables/use-users/preferences"; import { useRecipeFinderPreferences } from "~/composables/use-users/preferences";

View File

@@ -24,8 +24,8 @@ from mealie.schema.response.pagination import (
PaginationQuery, PaginationQuery,
RequestQuery, RequestQuery,
) )
from mealie.schema.response.query_filter import QueryFilterBuilder
from mealie.schema.response.query_search import SearchFilter from mealie.schema.response.query_search import SearchFilter
from mealie.services.query_filter.builder import QueryFilterBuilder
from ._utils import NOT_SET, NotSet 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_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationQuery 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 ..db.models._model_base import SqlAlchemyBase
from .repository_generic import HouseholdRepositoryGeneric 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.db.models.recipe import RecipeModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase 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() 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.db.models.recipe import RecipeModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase 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() logger = get_logger()

View File

@@ -1,4 +1,5 @@
# This file is auto-generated by gen_schema_exports.py # This file is auto-generated by gen_schema_exports.py
from .general import OpenAIText
from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
@@ -9,4 +10,5 @@ __all__ = [
"OpenAIRecipeIngredient", "OpenAIRecipeIngredient",
"OpenAIRecipeInstruction", "OpenAIRecipeInstruction",
"OpenAIRecipeNotes", "OpenAIRecipeNotes",
"OpenAIText",
] ]

View File

@@ -7,15 +7,6 @@ from .pagination import (
RecipeSearchQuery, RecipeSearchQuery,
RequestQuery, RequestQuery,
) )
from .query_filter import (
LogicalOperator,
QueryFilterBuilder,
QueryFilterBuilderComponent,
QueryFilterJSON,
QueryFilterJSONPart,
RelationalKeyword,
RelationalOperator,
)
from .query_search import SearchFilter from .query_search import SearchFilter
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
from .validation import ValidationResponse from .validation import ValidationResponse
@@ -25,13 +16,6 @@ __all__ = [
"FileTokenResponse", "FileTokenResponse",
"SuccessResponse", "SuccessResponse",
"SearchFilter", "SearchFilter",
"LogicalOperator",
"QueryFilterBuilder",
"QueryFilterBuilderComponent",
"QueryFilterJSON",
"QueryFilterJSONPart",
"RelationalKeyword",
"RelationalOperator",
"OrderByNullPosition", "OrderByNullPosition",
"OrderDirection", "OrderDirection",
"PaginationBase", "PaginationBase",

View File

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import re import re
from collections import deque from collections import deque
from enum import Enum
from typing import Any, cast from typing import Any, cast
from uuid import UUID 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.db.models._model_utils.guid import GUID
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
from .keywords import PlaceholderKeyword, RelationalKeyword
class RelationalKeyword(Enum): from .operators import LogicalOperator, RelationalOperator
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"
class QueryFilterJSONPart(MealieModel): class QueryFilterJSONPart(MealieModel):
@@ -161,6 +80,9 @@ class QueryFilterBuilderComponent:
else: else:
self.value = value self.value = value
# process placeholder keywords
self.value = PlaceholderKeyword.parse_value(self.value)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"[{self.attribute_name} {self.relationship.value} {self.value}]" 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

View File

@@ -6,6 +6,7 @@ from random import randint
from urllib.parse import parse_qsl, urlsplit from urllib.parse import parse_qsl, urlsplit
import pytest import pytest
from dateutil.relativedelta import relativedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from humps import camelize from humps import camelize
from pydantic import UUID4 from pydantic import UUID4
@@ -34,6 +35,7 @@ from mealie.schema.response.pagination import (
PaginationQuery, PaginationQuery,
) )
from mealie.schema.user.user import UserRatingUpdate from mealie.schema.user.user import UserRatingUpdate
from mealie.services.query_filter.builder import PlaceholderKeyword
from mealie.services.seeder.seeder_service import SeederService from mealie.services.seeder.seeder_service import SeederService
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string 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"] recipes_data = response.json()["items"]
assert len(recipes_data) == 1 assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_2.id) 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}"

View File

@@ -1,4 +1,4 @@
from mealie.schema.response.query_filter import ( from mealie.services.query_filter.builder import (
LogicalOperator, LogicalOperator,
QueryFilterBuilder, QueryFilterBuilder,
QueryFilterJSON, QueryFilterJSON,