Fix: Query Filter Date Comparisons Are Off By One Date (#2389)

* fixed erroneous date -> datetime conversion

* added tests for date and datetime bounds
This commit is contained in:
Michael Genson 2023-05-12 01:28:14 -05:00 committed by GitHub
parent 5d87b7e411
commit 8e2d50054c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 180 additions and 7 deletions

View File

@ -177,7 +177,8 @@ class QueryFilterComponent:
if isinstance(model_attr_type, sqltypes.Date | sqltypes.DateTime): if isinstance(model_attr_type, sqltypes.Date | sqltypes.DateTime):
try: try:
sanitized_values[i] = date_parser.parse(v) dt = date_parser.parse(v)
sanitized_values[i] = dt.date() if isinstance(model_attr_type, sqltypes.Date) else dt
except ParserError as e: except ParserError as e:
raise ValueError(f"invalid query string: unknown date or datetime format '{v}'") from e raise ValueError(f"invalid query string: unknown date or datetime format '{v}'") from e

View File

@ -1,6 +1,6 @@
import time import time
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import date, datetime, timedelta
from random import randint from random import randint
from urllib.parse import parse_qsl, urlsplit from urllib.parse import parse_qsl, urlsplit
@ -10,6 +10,7 @@ from humps import camelize
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_units import RepositoryUnit from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_category import CategorySave, TagSave from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit
@ -429,15 +430,186 @@ def test_pagination_filter_logical_namespace_conflict(database: AllRepositories,
def test_pagination_filter_datetimes( def test_pagination_filter_datetimes(
query_units: tuple[RepositoryUnit, IngredientUnit, IngredientUnit, IngredientUnit] query_units: tuple[RepositoryUnit, IngredientUnit, IngredientUnit, IngredientUnit]
): ):
units_repo = query_units[0] # units are created in order with increasing createdAt values
unit_1 = query_units[1] units_repo, unit_1, unit_2, unit_3 = query_units
unit_2 = query_units[2]
## GT
past_dt: datetime = unit_1.created_at - timedelta(seconds=1) # type: ignore
dt = past_dt.isoformat()
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>"{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 3
assert unit_1.id in unit_ids
assert unit_2.id in unit_ids
assert unit_3.id in unit_ids
dt = unit_1.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>"{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 2
assert unit_1.id not in unit_ids
assert unit_2.id in unit_ids
assert unit_3.id in unit_ids
dt = unit_2.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>"{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 1
assert unit_1.id not in unit_ids
assert unit_2.id not in unit_ids
assert unit_3.id in unit_ids
dt = unit_3.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>"{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 0
future_dt: datetime = unit_3.created_at + timedelta(seconds=1) # type: ignore
dt = future_dt.isoformat()
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>"{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 0
## GTE
past_dt = unit_1.created_at - timedelta(seconds=1) # type: ignore
dt = past_dt.isoformat()
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 3
assert unit_1.id in unit_ids
assert unit_2.id in unit_ids
assert unit_3.id in unit_ids
dt = unit_1.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 3
assert unit_1.id in unit_ids
assert unit_2.id in unit_ids
assert unit_3.id in unit_ids
dt = unit_2.created_at.isoformat() # type: ignore dt = unit_2.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"') query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items unit_results = units_repo.page_all(query).items
assert len(unit_results) == 2 unit_ids = set(unit.id for unit in unit_results)
assert unit_1.id not in [unit.id for unit in unit_results] assert len(unit_ids) == 2
assert unit_1.id not in unit_ids
assert unit_2.id in unit_ids
assert unit_3.id in unit_ids
dt = unit_3.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 1
assert unit_1.id not in unit_ids
assert unit_2.id not in unit_ids
assert unit_3.id in unit_ids
future_dt = unit_3.created_at + timedelta(seconds=1) # type: ignore
dt = future_dt.isoformat()
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
unit_ids = set(unit.id for unit in unit_results)
assert len(unit_ids) == 0
def test_pagination_filter_dates(api_client: TestClient, unique_user: TestUser):
yesterday = date.today() - timedelta(days=1)
today = date.today()
tomorrow = date.today() + timedelta(days=1)
day_after_tomorrow = date.today() + timedelta(days=2)
mealplan_today = CreatePlanEntry(date=today, entry_type="breakfast", title=random_string(), text=random_string())
mealplan_tomorrow = CreatePlanEntry(
date=tomorrow, entry_type="breakfast", title=random_string(), text=random_string()
)
for mealplan_to_create in [mealplan_today, mealplan_tomorrow]:
data = mealplan_to_create.dict()
data["date"] = data["date"].strftime("%Y-%m-%d")
response = api_client.post(api_routes.groups_mealplans, json=data, headers=unique_user.token)
assert response.status_code == 201
## Yesterday
params = {f"page": 1, "perPage": -1, "queryFilter": f"date >= {yesterday.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 2
fetched_mealplan_titles = set(mp["title"] for mp in response_json["items"])
assert mealplan_today.title in fetched_mealplan_titles
assert mealplan_tomorrow.title in fetched_mealplan_titles
params = {f"page": 1, "perPage": -1, "queryFilter": f"date > {yesterday.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 2
fetched_mealplan_titles = set(mp["title"] for mp in response_json["items"])
assert mealplan_today.title in fetched_mealplan_titles
assert mealplan_tomorrow.title in fetched_mealplan_titles
## Today
params = {f"page": 1, "perPage": -1, "queryFilter": f"date >= {today.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 2
fetched_mealplan_titles = set(mp["title"] for mp in response_json["items"])
assert mealplan_today.title in fetched_mealplan_titles
assert mealplan_tomorrow.title in fetched_mealplan_titles
params = {f"page": 1, "perPage": -1, "queryFilter": f"date > {today.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 1
fetched_mealplan_titles = set(mp["title"] for mp in response_json["items"])
assert mealplan_today.title not in fetched_mealplan_titles
assert mealplan_tomorrow.title in fetched_mealplan_titles
## Tomorrow
params = {f"page": 1, "perPage": -1, "queryFilter": f"date >= {tomorrow.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 1
fetched_mealplan_titles = set(mp["title"] for mp in response_json["items"])
assert mealplan_today.title not in fetched_mealplan_titles
assert mealplan_tomorrow.title in fetched_mealplan_titles
params = {f"page": 1, "perPage": -1, "queryFilter": f"date > {tomorrow.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 0
## Day After Tomorrow
params = {f"page": 1, "perPage": -1, "queryFilter": f"date >= {day_after_tomorrow.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 0
params = {f"page": 1, "perPage": -1, "queryFilter": f"date > {day_after_tomorrow.strftime('%Y-%m-%d')}"}
response = api_client.get(api_routes.groups_mealplans, params=params, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert len(response_json["items"]) == 0
def test_pagination_filter_booleans(query_units: tuple[RepositoryUnit, IngredientUnit, IngredientUnit, IngredientUnit]): def test_pagination_filter_booleans(query_units: tuple[RepositoryUnit, IngredientUnit, IngredientUnit, IngredientUnit]):