Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ def _matches_date_filter(
) -> bool:
"""Check whether any of the task's date fields fall within the range.

Mirrors the SQL date filtering logic: deadline, planned_start,
planned_end, actual_start and actual_end are combined with OR logic.
Mirrors the SQL date filtering logic in
``TaskQueryBuilder._build_date_filter_conditions``: deadline,
planned_start, planned_end, actual_start and actual_end are combined
with OR logic, and ``end_date`` is whole-day inclusive so a value that
carries a time on the boundary day still matches.

Args:
task: Task whose date fields are inspected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
(archived, status, tags, dates) are translated to SQL for optimal performance.
"""

from datetime import date
from datetime import date, timedelta

from sqlalchemy import func, or_, select
from sqlalchemy.sql.expression import ColumnElement
Expand Down Expand Up @@ -188,9 +188,14 @@ def _build_date_filter_conditions(
This helper method creates SQLAlchemy filter conditions for date range
filtering across all date fields (deadline, planned_start, planned_end,
actual_start, actual_end). It handles three cases for each field:
- Both start and end dates: field.between(start_date, end_date)
- Both start and end dates: field >= start_date AND field < end_date + 1 day
- Only start date: field >= start_date
- Only end date: field <= end_date
- Only end date: field < end_date + 1 day

The columns store datetimes, so ``end_date`` is turned into an exclusive
next-day bound. Comparing against the bare date would coerce it to
midnight and drop same-day values that carry a time, which disagrees
with ``TaskRepository._matches_date_filter``.

Args:
start_date: Minimum date for filtering (inclusive), or None
Expand All @@ -216,11 +221,14 @@ def _build_date_filter_conditions(

# Build conditions for each date field
for field in date_fields:
end_bound = end_date + timedelta(days=1) if end_date else None
if start_date and end_date:
date_conditions.append(field.between(start_date, end_date)) # type: ignore[attr-defined]
date_conditions.append(
(field >= start_date) & (field < end_bound) # type: ignore[operator]
)
elif start_date:
date_conditions.append(field >= start_date) # type: ignore[arg-type,operator]
elif end_date:
date_conditions.append(field <= end_date) # type: ignore[arg-type,operator]
date_conditions.append(field < end_bound) # type: ignore[arg-type,operator]

return date_conditions
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,19 @@ def test_with_date_filter_end_date_only(self):
assert "deadline" in result_str

def test_with_date_filter_both_dates(self):
"""Test that with_date_filter adds BETWEEN clause for date range."""
"""Test that with_date_filter adds a half-open range for a date range."""
base_stmt = select(TaskModel)
builder = TaskQueryBuilder(base_stmt)

result = builder.with_date_filter(
start_date=date(2025, 1, 1), end_date=date(2025, 12, 31)
).build()

# Should add WHERE clause with BETWEEN for date range
# Should add WHERE clause with an inclusive lower and exclusive upper bound
result_str = str(result).lower()
assert "where" in result_str
assert "between" in result_str
assert "deadline >=" in result_str
assert "deadline <" in result_str
assert "deadline" in result_str

def test_method_chaining_fluent_interface(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path

import pytest
from fixtures.repositories import InMemoryTaskRepository

from taskdog_core.domain.constants import MAX_TAGS_PER_TASK
from taskdog_core.domain.entities.task import Task, TaskStatus
Expand Down Expand Up @@ -537,6 +538,38 @@ def test_count_tasks_with_date_filter(self):
before_feb_count = self.repository.count_tasks(end_date=date(2025, 2, 28))
assert before_feb_count == 3 # task1, task2, task4

def test_date_filter_end_date_includes_same_day_time(self):
"""Test end_date is whole-day inclusive for time-bearing values.

The columns store datetimes, so a bare ``end_date`` bound is coerced to
midnight and used to drop same-day values that carry a time. That
disagrees with the default ``TaskRepository.get_filtered()``, which
compares dates only.
"""
task = Task(
id=1, name="Boundary", priority=1, deadline=datetime(2025, 2, 28, 10, 0)
)
self.repository.save(task)

end_date = date(2025, 2, 28)
in_memory = InMemoryTaskRepository()
in_memory.save(task)

assert len(self.repository.get_filtered(end_date=end_date)) == 1
assert (
len(
self.repository.get_filtered(
start_date=date(2025, 2, 1), end_date=end_date
)
)
== 1
)
assert self.repository.count_tasks(end_date=end_date) == 1
# Both repository implementations must agree on the boundary.
assert len(in_memory.get_filtered(end_date=end_date)) == 1
# The day after the deadline is still excluded.
assert len(self.repository.get_filtered(end_date=date(2025, 2, 27))) == 0

def test_count_tasks_with_combined_filters(self):
"""Test count_tasks() with multiple filters combined."""
# Create diverse test data
Expand Down