Item History & Live Search-as-You-Type

Food Tracker (FastAPI)

Chapter 7 · Item History & Live Search-as-You-Type

Every item ever added stays in the items table forever — Chapter 2's own design, active or used. This chapter surfaces that whole history, searchable in real time as the user types.

The Search Route

# routers/items.py @router.get("/search", response_model=list[schemas.ItemResponse]) def search_items(q: str = "", db: Session = Depends(get_db)): return ( db.query(models.Item) .filter(models.Item.name.ilike(f"%{q}%")) .order_by(models.Item.added_at.desc()) .limit(50) .all() )

Deliberately no status filter, exactly like every sibling course's own search route — this searches the entire history, active and used items both, since re-adding something bought before is exactly the case this endpoint exists for.

Zooming out: three of four courses share this advantage
.ilike() is SQLAlchemy's own built-in, explicitly-named case-insensitive LIKE — no separate lowercase field to maintain, the same real advantage Food Tracker (Django)'s icontains lookup and Food Tracker (React + Express)'s own raw SQLite LIKE both already claimed for themselves. All three of this quartet's SQL-backed courses get native, case-insensitive substring search essentially for free, each expressed in that framework's own idiom — ilike() here, icontains in Django, a plain LIKE in Express. Only Food Tracker (React + Firebase) needed a workaround at all, maintaining its own nameLower shadow field specifically because Firestore has no equivalent native capability. This isn't really a FastAPI-specific advantage — it's a relational-database advantage the three SQL courses in this quartet all happen to share, each one naming it in its own equivalent chapter.

A Debounce, Written as Plain Functions

The same underlying idea as Food Tracker (React + Express)'s own useDebouncedSearch hook, here with no hook at all — just setTimeout/clearTimeout and a module-level variable:

// static/app.js let searchTimeoutId = null; function onSearchInput(query) { clearTimeout(searchTimeoutId); searchTimeoutId = setTimeout(() => runSearch(query), 300); } async function runSearch(query) { const response = await fetch(`/api/items/search?q=${encodeURIComponent(query)}`); const results = await response.json(); renderSearchResults(results); } document.getElementById("search-input").addEventListener("input", (e) => { onSearchInput(e.target.value); });

clearTimeout(searchTimeoutId) at the start of onSearchInput cancels whatever timer the previous keystroke scheduled, before scheduling a new one — the same underlying discipline as Chapter 4's stopScanner() calls, applied here to a timer instead of a camera stream.

Lower stakes than Chapter 4's cleanup, worth noting honestly
Forgetting clearTimeout here would mean an extra, unnecessary search request firing occasionally — a wasted network call, nothing more. Chapter 4's own stopScanner() omission would leave a camera physically running in the background indefinitely. Both are the same underlying "clean up what the last run scheduled" pattern, but the actual cost of getting it wrong is meaningfully different — worth knowing which mistakes in a framework-free app are merely wasteful versus genuinely harmful.
LIMIT 50 is a scope decision, not real pagination
A history large enough to exceed 50 matches for a single search term would simply have its remaining results cut off, with no "load more" or page-through mechanism built here. A genuinely large history would need real pagination — outside this course's own realistic scope, but worth knowing as an honest limit rather than assuming the current query handles every possible case.

Where This Course Is Headed

Marking items used next — a PATCH endpoint transitioning status and clearing the expiry date without deleting the row.

Hands-On Exercises

Exercise 1

Explain why this chapter says the native case-insensitive substring search advantage isn't really FastAPI-specific, naming which three courses in the quartet share it and which one doesn't.

📄 View solution
Exercise 2

Explain why clearTimeout(searchTimeoutId) is described as the same underlying pattern as Chapter 4's stopScanner() calls, and why the two are still described as having meaningfully different stakes if forgotten.

📄 View solution
Exercise 3

Explain why the search route deliberately has no status filter, unlike Chapter 6's own alerts route.

📄 View solution

Chapter 7 Quick Reference

  • Route: GET /search?q=... — ilike('%'+q+'%'), no status filter, limited to 50 results
  • Quartet-wide finding: Django's icontains, Express's LIKE, and this course's ilike() all share the same native advantage — only the Firebase sibling needed a nameLower workaround
  • Debounce: plain setTimeout/clearTimeout, the same underlying pattern as Chapter 4's stopScanner(), but lower stakes if forgotten
  • Honest limit: LIMIT 50 is a scope decision, not real pagination
  • Next chapter: Marking Items Used