There are multiple approaches to filter our data we can choose from. For this post I use FastAPI Filter, because it offers us a lot of flexibility. We can use everything it offers or, as I prefer, start small and add only the keywords we need.
By default, FastAPI Filter supports the following operators that we can append to our fields (field__[operator]):
neq – not equals
gt - greater than
gte - greater than or equal to
in – in a list of values
isnull – is NULL
lt - less than
lte - less than or equal to
not/ne – not or not equal to a specified value
not_in/nin – not in a list of values
like/ilike – like with case sensitive or case insensitive
Installation
We can install the fastapi-filter package with this command to support SQLAlchemy:
Even when we want to filter in FastAPI, we must make sure that we can use the filter with our database. Therefore, we start the implementation at the bottom and go upwards through our application.
Here are the tests we need to verify that the first filters work:
from..models.task_filterimportTaskFilter...@pytest.mark.asyncioasyncdeftest_filter_empty_filter_gives_all_entries(with_db):store=DataStoreDb(with_db)awaitstore.add(TaskInput(name="counter",priority=1,due_date=date.today(),done=False))awaitstore.add(TaskInput(name="A second entry",priority=2,due_date=date.today(),done=True))filter=TaskFilter()entries=awaitstore.filter(filter)assertlen(entries)>=2@pytest.mark.asyncioasyncdeftest_filter_with_filter_for_done_gives_only_done_entries(with_db):store=DataStoreDb(with_db)awaitstore.add(TaskInput(name="counter",priority=1,due_date=date.today(),done=False))awaitstore.add(TaskInput(name="A second entry",priority=2,due_date=date.today(),done=True))filter=TaskFilter()filter.done=Trueentries=awaitstore.filter(filter)assertlen(entries)>=1forentryinentries:assertentry.done==True@pytest.mark.asyncioasyncdeftest_filter_with_filter_for_name_gives_only_matching_entries(with_db):store=DataStoreDb(with_db)awaitstore.add(TaskInput(name="counter",priority=1,due_date=date.today(),done=False))awaitstore.add(TaskInput(name="Create a filter",priority=2,due_date=date.today(),done=True))filter=TaskFilter()filter.name="Create a filter"entries=awaitstore.filter(filter)assertlen(entries)==1assertentries[0].name=="Create a filter"@pytest.mark.asyncioasyncdeftest_filter_with_search_gives_only_matching_entries(with_db):store=DataStoreDb(with_db)awaitstore.add(TaskInput(name="Search for item",priority=1,due_date=date.today(),done=False))awaitstore.add(TaskInput(name="Create a Search",priority=2,due_date=date.today(),done=True))filter=TaskFilter()filter.search="Search"entries=awaitstore.filter(filter)assertlen(entries)>=2forentryinentries:assert"Search"inentry.name
The tests currently fail, then we have no TaskFilter class and no filter() method on our data store.
Create the TaskFilter
The main component of FastAPI Filter is our filter object. We need to specify what fields we want to filter on and how we want to order the results.
If we specify our field as it is, we can filter for an exact match. If we want to use an operator from the list above, we can use our field name, add two _ and then use the operator as we did with due_date__lte.
The order_by uses the field name in ascending order if we do not overwrite it. That way we always get a sorted result.
The search field allows us to search for a part of the name. If we want to include other fields, we can add them to the list of search_model_fields.
As a final point, make sure that you set the default value to None. That way all the filters are optional. If you omit the None value, you turn them into required fields.
Extend the data store
In our data store, we add a new filter() method that uses the TaskFilter to expand the query with a WHERE and an ORDER BY clause:
I had to add an * in front of entry to convert it to our TaskOutput object. Otherwise, I got an error about a missing id attribute.
Our new tests for the data store should now work. If that is the case, we can go one layer up to FastAPI.
Change the endpoint tests
With our new FastAPI Filter package, the syntax to filter the tasks changes a bit. We can change the syntax of the existing tests that filter the tasks and add a few new tests to check that the order_by works as expected:
@pytest.mark.asyncioasyncdeftest_show_all_tasks():awaitprepare_task("a first task")awaitprepare_task("a second task")awaitprepare_task("a third task")response=client.get("/api/todo")assertresponse.status_code==200tasks=response.json()assertlen(tasks)>=3@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_are_not_done():awaitprepare_task("a finished task",done=True)awaitprepare_task("an open task",done=False)response=client.get("/api/todo?done=false")assertresponse.status_code==200tasks=response.json()done=[taskfortaskintasksiftask['done']==True]assertlen(done)==0@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_are_due_within_five_days():awaitprepare_task("in 10 days",due_date=date.today()+timedelta(days=10))awaitprepare_task("in 4 days",due_date=date.today()+timedelta(days=4))response=client.get(f"/api/todo?done=false&due_date__lte={date.today()+timedelta(days=5)}")assertresponse.status_code==200tasks=response.json()assertlen(tasks)>=1larger=[taskfortaskintasksifdate.fromisoformat(task['due_date'])>date.today()+timedelta(days=5)]assertlen(larger)==0@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_match_search_criteria_sorted_by_name():awaitprepare_task("485960 A",due_date=date.today())awaitprepare_task("485960 B",due_date=date.today())awaitprepare_task("485960 C",due_date=date.today())response=client.get(f"/api/todo?search=485960")assertresponse.status_code==200tasks=response.json()assertlen(tasks)==3asserttasks[0]["name"]=="485960 A"asserttasks[1]["name"]=="485960 B"asserttasks[2]["name"]=="485960 C"@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_match_search_criteria_sorted_by_name_descending():awaitprepare_task("5780383 A",due_date=date.today())awaitprepare_task("5780383 B",due_date=date.today())awaitprepare_task("5780383 C",due_date=date.today())response=client.get(f"/api/todo?search=5780383&order_by=-name")assertresponse.status_code==200tasks=response.json()assertlen(tasks)==3asserttasks[0]["name"]=="5780383 C"asserttasks[1]["name"]=="5780383 B"asserttasks[2]["name"]=="5780383 A"
The endpoint tests that filter the tasks should all fail. To make them pass we need to wire-up the filters in our endpoint.
Change the /tasks endpoint
Our show_all_tasks() method needs to use our TaskFilter as a dependency and pass that filter to our data store. Our new Filter package comes with the FilterDepends keyword, that does all the magic to translate the query string to our TaskFilter object:
We can now run all our tests and they should pass.
Updated documentation
If we start our application and head to the /docs endpoint, we find the filter fields as part of the documentation:
Next
With FastAPI Filter we can create our filter object and let the package write the correct query for us. We can focus on the filters we want and skip the rest for later. That offers us a lot of flexibility and still gives us a much better solution than the hand-written one we started with.
There is one downside of our change: our clients must adapt to the new way, or they can no longer use our service. Next week we look at ways to prevent this with an API endpoint versioning strategy.