diff --git a/docs/docs/documentation/getting-started/api-usage.md b/docs/docs/documentation/getting-started/api-usage.md index bf00a3977..9323776d1 100644 --- a/docs/docs/documentation/getting-started/api-usage.md +++ b/docs/docs/documentation/getting-started/api-usage.md @@ -89,6 +89,26 @@ This filter will find all recipes that don't start with the word "Test":
This filter will find all recipes that have particular slugs:
`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:
+`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:
`user.username = "SousChef20220320"` diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index eed1ec2df..5539f966a 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -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; diff --git a/frontend/components/Domain/QueryFilterBuilder.vue b/frontend/components/Domain/QueryFilterBuilder.vue index 85b00ad25..ca330cbb4 100644 --- a/frontend/components/Domain/QueryFilterBuilder.vue +++ b/frontend/components/Domain/QueryFilterBuilder.vue @@ -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"; diff --git a/frontend/composables/use-query-filter-builder.ts b/frontend/composables/use-query-filter-builder.ts index c811d66ae..2b731cff3 100644 --- a/frontend/composables/use-query-filter-builder.ts +++ b/frontend/composables/use-query-filter-builder.ts @@ -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; diff --git a/frontend/composables/use-users/preferences.ts b/frontend/composables/use-users/preferences.ts index 103678788..c8df3b011 100644 --- a/frontend/composables/use-users/preferences.ts +++ b/frontend/composables/use-users/preferences.ts @@ -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; diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts index e93386e0f..36f1c086f 100644 --- a/frontend/lib/api/types/cookbook.ts +++ b/frontend/lib/api/types/cookbook.ts @@ -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; diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index aa4f27115..ca07d873e 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -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; diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts index 81b81325d..d1acd7d5c 100644 --- a/frontend/lib/api/types/non-generated.ts +++ b/frontend/lib/api/types/non-generated.ts @@ -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; +} diff --git a/frontend/lib/api/types/openai.ts b/frontend/lib/api/types/openai.ts index 10b506402..a18ae9b3c 100644 --- a/frontend/lib/api/types/openai.ts +++ b/frontend/lib/api/types/openai.ts @@ -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 {} diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 77858e3d6..ca19b1d29 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -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; } diff --git a/frontend/lib/api/types/response.ts b/frontend/lib/api/types/response.ts index d77f44084..68f7ba9c6 100644 --- a/frontend/lib/api/types/response.ts +++ b/frontend/lib/api/types/response.ts @@ -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; diff --git a/frontend/pages/g/[groupSlug]/recipes/finder/index.vue b/frontend/pages/g/[groupSlug]/recipes/finder/index.vue index 39df6cccd..04abd81f0 100644 --- a/frontend/pages/g/[groupSlug]/recipes/finder/index.vue +++ b/frontend/pages/g/[groupSlug]/recipes/finder/index.vue @@ -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"; diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 3565a1b8f..414574105 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -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 diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index c76489a5f..fe2c6d588 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -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 diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index 8d7761e3c..29b543d76 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -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() diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py index 3358e503c..8e803bd6c 100644 --- a/mealie/schema/meal_plan/plan_rules.py +++ b/mealie/schema/meal_plan/plan_rules.py @@ -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() diff --git a/mealie/schema/openai/__init__.py b/mealie/schema/openai/__init__.py index e153fb35f..0f67fb866 100644 --- a/mealie/schema/openai/__init__.py +++ b/mealie/schema/openai/__init__.py @@ -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", ] diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py index 07e1c46b8..a323b6e34 100644 --- a/mealie/schema/response/__init__.py +++ b/mealie/schema/response/__init__.py @@ -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", diff --git a/mealie/services/query_filter/__init__.py b/mealie/services/query_filter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mealie/schema/response/query_filter.py b/mealie/services/query_filter/builder.py similarity index 90% rename from mealie/schema/response/query_filter.py rename to mealie/services/query_filter/builder.py index ef093a89d..3f65aa179 100644 --- a/mealie/schema/response/query_filter.py +++ b/mealie/services/query_filter/builder.py @@ -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}]" diff --git a/mealie/services/query_filter/keywords.py b/mealie/services/query_filter/keywords.py new file mode 100644 index 000000000..0aa98ea41 --- /dev/null +++ b/mealie/services/query_filter/keywords.py @@ -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 diff --git a/mealie/services/query_filter/operators.py b/mealie/services/query_filter/operators.py new file mode 100644 index 000000000..1757c5607 --- /dev/null +++ b/mealie/services/query_filter/operators.py @@ -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 diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py index ecee158a8..61e216319 100644 --- a/tests/unit_tests/repository_tests/test_pagination.py +++ b/tests/unit_tests/repository_tests/test_pagination.py @@ -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}" diff --git a/tests/unit_tests/repository_tests/test_query_filter_builder.py b/tests/unit_tests/repository_tests/test_query_filter_builder.py index 9771b1b1b..f930088bf 100644 --- a/tests/unit_tests/repository_tests/test_query_filter_builder.py +++ b/tests/unit_tests/repository_tests/test_query_filter_builder.py @@ -1,4 +1,4 @@ -from mealie.schema.response.query_filter import ( +from mealie.services.query_filter.builder import ( LogicalOperator, QueryFilterBuilder, QueryFilterJSON,