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>
|
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"`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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
|
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}]"
|
||||||
|
|
||||||
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
|
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}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from mealie.schema.response.query_filter import (
|
from mealie.services.query_filter.builder import (
|
||||||
LogicalOperator,
|
LogicalOperator,
|
||||||
QueryFilterBuilder,
|
QueryFilterBuilder,
|
||||||
QueryFilterJSON,
|
QueryFilterJSON,
|
||||||
|
|||||||
Reference in New Issue
Block a user