Skip to content

Commit 8602e87

Browse files
Merge pull request #7 from coderabbitai/feature/todo-search
Add text search to the todo list endpoint
2 parents 17fcf93 + e5f028f commit 8602e87

3 files changed

Lines changed: 73 additions & 2 deletions

File tree

‎backend/app/routes/todos.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, timezone
44

55
from fastapi import APIRouter, Depends, HTTPException, Query
6-
from sqlalchemy import select
6+
from sqlalchemy import or_, select
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99
from app.database import get_db
@@ -13,20 +13,42 @@
1313
router = APIRouter(prefix="/api/todos", tags=["todos"])
1414

1515

16+
def _like_pattern(term: str) -> str:
17+
"""Build a case-insensitive "contains" pattern, escaping LIKE wildcards in the term."""
18+
# Escape the backslash first so the escapes added for % and _ are not doubled.
19+
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
20+
return f"%{escaped}%"
21+
22+
1623
@router.get("", response_model=list[TodoResponse])
1724
async def list_todos(
1825
completed: bool | None = Query(None, description="Filter by completion status"),
1926
include_archived: bool = Query(False, description="Include archived todos"),
27+
search: str | None = Query(
28+
None,
29+
min_length=1,
30+
max_length=100,
31+
description="Only return todos whose title or description contains this text",
32+
),
2033
db: AsyncSession = Depends(get_db),
2134
) -> list[Todo]:
22-
"""Retrieve all todos, optionally filtered by completion status."""
35+
"""Retrieve todos, optionally filtered by completion status or a search term."""
2336
query = select(Todo).order_by(Todo.created_at.desc())
2437

2538
if completed is not None:
2639
query = query.where(Todo.completed == completed)
2740

2841
if not include_archived:
2942
query = query.where(Todo.archived_at.is_(None))
43+
44+
if search:
45+
pattern = _like_pattern(search)
46+
query = query.where(
47+
or_(
48+
Todo.title.ilike(pattern, escape="\\"),
49+
Todo.description.ilike(pattern, escape="\\"),
50+
)
51+
)
3052

3153
result = await db.execute(query)
3254
todos = result.scalars().all()

‎backend/tests/test_todos.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,50 @@ async def test_archive_nonexistent_todo(client: AsyncClient):
210210
response = await client.post("/api/todos/9999/archive")
211211
assert response.status_code == 404
212212
assert response.json()["detail"] == "Todo not found"
213+
214+
215+
@pytest.mark.asyncio
216+
async def test_search_matches_title_and_description_case_insensitively(client: AsyncClient):
217+
"""Test searching todos by text in the title or description."""
218+
await client.post("/api/todos", json={"title": "Buy Milk"})
219+
await client.post(
220+
"/api/todos",
221+
json={"title": "Plan trip", "description": "Book flights and a MILK run"},
222+
)
223+
await client.post("/api/todos", json={"title": "Unrelated"})
224+
225+
response = await client.get("/api/todos?search=milk")
226+
assert response.status_code == 200
227+
assert sorted(todo["title"] for todo in response.json()) == ["Buy Milk", "Plan trip"]
228+
229+
230+
@pytest.mark.asyncio
231+
async def test_search_without_matches_returns_empty_list(client: AsyncClient):
232+
"""Test that a search with no matches returns an empty list."""
233+
await client.post("/api/todos", json={"title": "Buy milk"})
234+
235+
response = await client.get("/api/todos?search=bread")
236+
assert response.status_code == 200
237+
assert response.json() == []
238+
239+
240+
@pytest.mark.asyncio
241+
async def test_search_treats_wildcards_literally(client: AsyncClient):
242+
"""Test that % and _ in the search term are not interpreted as LIKE wildcards."""
243+
await client.post("/api/todos", json={"title": "Reach 100% coverage"})
244+
await client.post("/api/todos", json={"title": "Reach 100 points"})
245+
246+
response = await client.get("/api/todos?search=100%25")
247+
assert [todo["title"] for todo in response.json()] == ["Reach 100% coverage"]
248+
249+
250+
@pytest.mark.asyncio
251+
async def test_search_combines_with_completed_filter(client: AsyncClient):
252+
"""Test combining search with the completed filter."""
253+
open_todo = await client.post("/api/todos", json={"title": "Write report"})
254+
done_todo = await client.post("/api/todos", json={"title": "Write changelog"})
255+
await client.patch(f"/api/todos/{done_todo.json()['id']}", json={"completed": True})
256+
257+
response = await client.get("/api/todos?search=write&completed=true")
258+
assert [todo["id"] for todo in response.json()] == [done_todo.json()["id"]]
259+
assert open_todo.json()["id"] not in [todo["id"] for todo in response.json()]

‎docs/api-reference.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ Returns todos, newest first. Archived todos are hidden unless you ask for them.
3030
| --- | --- | --- |
3131
| `completed` | boolean | Return only completed or only open todos. |
3232
| `include_archived` | boolean | Include archived todos. Defaults to `false`. |
33+
| `search` | string | Only return todos whose title or description contains the text, ignoring case. 1 to 100 characters. |
3334

3435
```bash
3536
curl "http://localhost:8000/api/todos?completed=false"
37+
curl "http://localhost:8000/api/todos?search=milk"
3638
```
3739

3840
### Create a todo

0 commit comments

Comments
 (0)