Until now we kept our data in a variable. While that worked with an example application, the data vanishes as soon as we restart our API. To get a more realistic application, we need to persist data for a longer time. Let us explore how we can integrate SQLAlchemy with FastAPI.
The extended to-do application
The current state of the to-do application is a good basic example for FastAPI. I copied the application as it is after the introduction of the router into a new folder named extended_todo. Here we can add the new features for the rest of this series on FastAPI.
When it comes to the integration of SQLAlchemy in FastAPI, we get a lot of flexibility. For the models and entities, we can choose between these 2 options:
Dedicated models for Pydantic and SQLAlchemy.
One combined model for Pydantic and SQLAlchemy (for example with SQLModel)
When we know how to split the models, we need to decide how we want to access the database:
Through a repository that hides the SQLAlchemy commands from our application.
All options have their pros and cons, so it is up to the specific application to make the trade-off.
I like the in-memory data store I currently have with its separation of concerns and the two Pydantic models to split the input from the output. I will continue with this idea and create a separate entity Task that is a SQLAlchemy entity that maps to the table. My datastore will continue to use the Pydantic models as input and output but works with the Task entity behind the scenes.
The SQLAlchemy configuration
We need to tell SQLAlchemy what kind of database we want to use and create some setup code to wire everything together. For that we add the database.py file inside the data folder with this code:
The create_session_factory() method sets everything up and creates a session factory for us, that we will use as our entry into the database. This is similar to the official tutorial, but I prefer to set the file name for the database from the outside.
The database models
To create our classes that match our tables, we need a base class that creates the SQLAlchemy magic behind the scenes. We can put that code into entitybase.py in the data folder:
As a final step we create the file data/__all_models.py to have one place that contains all our entities – that way SQLAlchemy can create them when it initialises the database:
We continue our test first approach and start with the tests for the database store we want to use with SQLAlchemy. As always, start with a test, implement enough code to make it work and then repeat. To shorten this post, here are all the tests that we need:
fromsqlalchemy.ormimportSessionfromdatetimeimportdate,datetimefrom..models.todoimportTaskInput,TaskOutputfrom.entitiesimportTaskclassDataStoreDb:def__init__(self,db:Session):self.db=dbdefadd(self,entry:TaskInput)->TaskOutput:task=Task(created_at=datetime.now(),**dict(entry))self.db.add(task)self.db.commit()returnself.__to_output(task)defget(self,id:int)->TaskOutput:result=self.db.query(Task) \
.filter(Task.id==id) \
.first()ifresult:returnself.__to_output(result)else:returnNonedefall(self):entries=self.db.query(Task).all()results=[]forentryinentries:results.append(self.__to_output(entry))returnresultsdefdelete(self,id:int)->None:entry=self.db.query(Task) \
.filter(Task.id==id) \
.first()ifentry:self.db.delete(entry)self.db.commit()defupdate(self,id:int,update:TaskInput)->TaskOutput:entry=self.db.query(Task) \
.filter(Task.id==id) \
.first()ifentry:entry.name=update.nameentry.priority=update.priorityentry.due_date=update.due_dateentry.done=update.doneself.db.commit()returnself.__to_output(entry)else:raiseValueError(f"no taks known with id '{id}'")def__to_output(self,entity:Task)->TaskOutput:returnTaskOutput(id=entity.id,name=entity.name,priority=entity.priority,due_date=entity.due_date,done=entity.done,created_at=date.today())
The private method __to_output() turns our Task into a TaskOutput. The different data types of created_at are there by design so that we can see how we can create a converter to map from the database objects to the ones we use in FastAPI.
With this code in place, we can run our tests. Everything should pass, including the old tests.
Use the data store in the FastAPI application
Since we moved the /todo endpoints into a router file, we need to open routers/todo.py, wire up the database and replace the datastore:
from..data.databaseimportcreate_session_factoryrouter=APIRouter()asyncdefget_db():""" Creates the datastore """db_file=os.path.join(os.path.dirname(__file__),'..','db','todo_api.sqlite')factory=create_session_factory(db_file)session=factory()db=DataStoreDb(session)try:yielddbfinally:session.close()# db = DataStoreDb(session)
We need to change all endpoint methods and add the dependency for our get_db() method:
The rest of the code stays the same. Even better, our tests for our endpoints need not to change at all.
Next
We moved from the in-memory data store to SQLAlchemy, and our API works as before. We have the tests to check that this is not only a claim, but reality.
However, there is something that could be a problem. Our endpoint tests write into the database we defined in the router and not into the same place as the datastore tests. Next week we optimise the database tests and fix that problem.