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>
`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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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