SQL Repository
Overview
A natural step in this journey, is to create a new repository, that uses the same interface that the current being used (in-memory repository), and see that everything continues working. So, let's create an SQL repository, using the SQLAlchemy package to handle the database engine stuff.
To start, we need to add the SQLAlchemy package to requirements.txt requirements.txt
@@ -1,3 +1,4 @@
fastapi==0.61.0
pytest==6.0.1
requests==2.24.0
+SQLAlchemy==1.3.19
and install it:
pip install -r requirements.txt
Example
With all the necessary updates done, we continue creating a new test file:
touch tests/test_05_sqlalchemy_repository.py
First test
Let's create an initial test, to create an sql repository and add a book. First of all, we define the
SQLBookRepository
class, that inherits from the same BookRepository
interface used by the InMemoryBooksRepository
.
And then, we define the initial test.
tests/test_05_sqlalchemy_repository.py
@@ -0,0 +1,32 @@
+from typing import List
+from uuid import UUID
+
+from sqlalchemy.orm import Session
+
+from tests.repositories import BookRepository
+from tests.schemas import BookInDB, Book
+
+
+class SQLBookRepository(BookRepository):
+ def __init__(self, db: Session):
+ self.db = db
+
+ def add(self, book: Book) -> BookInDB:
+ pass
+
+ def get(self, book_id: UUID) -> BookInDB:
+ pass
+
+ def list(self) -> List[BookInDB]:
+ pass
+
+
+def test_create_new_book_in_sql_repository_successfully(db_session: Session, a_book) -> None:
+ repo = SQLBookRepository(db_session)
+ created_book = repo.add(book=a_book)
+ assert created_book
+ assert isinstance(created_book, BookInDB)
+ assert created_book.title == a_book.title
+ assert created_book.author == a_book.author
+
+
Database fixtures
To work with a real SQL engine, we need to manage sessions, connections, and a bunch of things that sqlalchemy makes easier.
We create a new fixture db_connection
that creates a new instance of an sqlite engine in memory, and lasts for the entire test session.
And then, another fixture to create a db_session for every test:
tests/test_05_sqlalchemy_repository.py
@@ -1,32 +1,48 @@
+from contextlib import closing
from typing import List
from uuid import UUID
-from sqlalchemy.orm import Session
+import pytest
+from sqlalchemy.engine import Connection, create_engine
+from sqlalchemy.orm import Session, sessionmaker
from tests.repositories import BookRepository
from tests.schemas import BookInDB, Book
class SQLBookRepository(BookRepository):
def __init__(self, db: Session):
self.db = db
def add(self, book: Book) -> BookInDB:
pass
def get(self, book_id: UUID) -> BookInDB:
pass
def list(self) -> List[BookInDB]:
pass
+@pytest.fixture(scope="session")
+def db_connection() -> Connection:
+ db_url = "sqlite:///:memory:?check_same_thread=False"
+ engine = create_engine(db_url, pool_pre_ping=True)
+ with engine.connect() as connection:
+ yield connection
+
+
+@pytest.fixture(scope="function")
+def db_session(db_connection: Connection) -> Session:
+ session_maker = sessionmaker(autocommit=False, autoflush=False, bind=db_connection)
+ with closing(session_maker()) as session:
+ yield session
+
+
def test_create_new_book_in_sql_repository_successfully(db_session: Session, a_book) -> None:
repo = SQLBookRepository(db_session)
created_book = repo.add(book=a_book)
assert created_book
assert isinstance(created_book, BookInDB)
assert created_book.title == a_book.title
assert created_book.author == a_book.author
-
-
Create Database Tables
To work with an SQL engine, we need to define tables and column types. To do this, we use the declarative
method of
sqlalchemy to create a new class Base
, from which all data models will inherit, and which can also be used
to create and drop all the database tables for each session.
tests/test_05_sqlalchemy_repository.py
@@ -1,42 +1,58 @@
from contextlib import closing
from typing import List
-from uuid import UUID
+from uuid import UUID, uuid4
import pytest
+from sqlalchemy import Column, String
from sqlalchemy.engine import Connection, create_engine
+from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
from tests.repositories import BookRepository
from tests.schemas import BookInDB, Book
class SQLBookRepository(BookRepository):
def __init__(self, db: Session):
self.db = db
def add(self, book: Book) -> BookInDB:
pass
def get(self, book_id: UUID) -> BookInDB:
pass
def list(self) -> List[BookInDB]:
pass
+Base = declarative_base()
+
+
+class BookModel(Base):
+ __tablename__ = 'books'
+
+ # It is not recommended to store uuid as str, but for these tests is ok
+ id = Column(String, primary_key=True, index=True, default=str(uuid4()))
+ title = Column(String)
+ author = Column(String)
+
+
@pytest.fixture(scope="session")
def db_connection() -> Connection:
db_url = "sqlite:///:memory:?check_same_thread=False"
engine = create_engine(db_url, pool_pre_ping=True)
with engine.connect() as connection:
+ Base.metadata.create_all(bind=connection)
yield connection
+ Base.metadata.drop_all(bind=connection)
@pytest.fixture(scope="function")
def db_session(db_connection: Connection) -> Session:
session_maker = sessionmaker(autocommit=False, autoflush=False, bind=db_connection)
with closing(session_maker()) as session:
yield session
def test_create_new_book_in_sql_repository_successfully(db_session: Session, a_book) -> None:
Add a New Book
Next step is to write the code to add a book to the database: tests/test_05_sqlalchemy_repository.py
@@ -1,30 +1,36 @@
from contextlib import closing
from typing import List
from uuid import UUID, uuid4
import pytest
+from fastapi.encoders import jsonable_encoder
from sqlalchemy import Column, String
from sqlalchemy.engine import Connection, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
from tests.repositories import BookRepository
from tests.schemas import BookInDB, Book
class SQLBookRepository(BookRepository):
def __init__(self, db: Session):
self.db = db
def add(self, book: Book) -> BookInDB:
- pass
+ book_data = jsonable_encoder(book)
+ book = BookModel(**book_data)
+ self.db.add(book)
+ self.db.commit()
+ self.db.refresh(book)
+ return BookInDB(**book.__dict__)
def get(self, book_id: UUID) -> BookInDB:
pass
def list(self) -> List[BookInDB]:
pass
Base = declarative_base()
Get a Book
Let's write a test to fetch information from the database: tests/test_05_sqlalchemy_repository.py
@@ -61,10 +61,21 @@
yield session
def test_create_new_book_in_sql_repository_successfully(db_session: Session, a_book) -> None:
repo = SQLBookRepository(db_session)
created_book = repo.add(book=a_book)
assert created_book
assert isinstance(created_book, BookInDB)
assert created_book.title == a_book.title
assert created_book.author == a_book.author
+
+
+def test_get_book_from_sql_repository_successfully(db_session: Session, a_book) -> None:
+ repo = SQLBookRepository(db_session)
+ created_book = repo.add(book=a_book)
+ book_from_db = repo.get(book_id=created_book.id)
+ assert book_from_db
+ assert isinstance(book_from_db, BookInDB)
+ assert created_book.id == book_from_db.id
+ assert a_book.title == book_from_db.title
+ assert a_book.author == book_from_db.author
And to implmemet the get
method to make the test work:
tests/test_05_sqlalchemy_repository.py
@@ -19,21 +19,22 @@
def add(self, book: Book) -> BookInDB:
book_data = jsonable_encoder(book)
book = BookModel(**book_data)
self.db.add(book)
self.db.commit()
self.db.refresh(book)
return BookInDB(**book.__dict__)
def get(self, book_id: UUID) -> BookInDB:
- pass
+ book = self.db.query(BookModel).filter(BookModel.id == str(book_id)).first()
+ return BookInDB(**book.__dict__)
def list(self) -> List[BookInDB]:
pass
Base = declarative_base()
class BookModel(Base):
__tablename__ = 'books'
Get Books List
And finally, let's do the same for a list of books: tests/test_05_sqlalchemy_repository.py
@@ -22,32 +22,33 @@
book = BookModel(**book_data)
self.db.add(book)
self.db.commit()
self.db.refresh(book)
return BookInDB(**book.__dict__)
def get(self, book_id: UUID) -> BookInDB:
book = self.db.query(BookModel).filter(BookModel.id == str(book_id)).first()
return BookInDB(**book.__dict__)
- def list(self) -> List[BookInDB]:
- pass
+ def list(self, skip=0, offset=5) -> List[BookInDB]:
+ books = self.db.query(BookModel).offset(skip).limit(offset).all()
+ return [BookInDB(**book.__dict__) for book in books]
Base = declarative_base()
class BookModel(Base):
__tablename__ = 'books'
# It is not recommended to store uuid as str, but for these tests is ok
- id = Column(String, primary_key=True, index=True, default=str(uuid4()))
+ id = Column(String, primary_key=True, index=True, default=lambda x: str(uuid4()))
title = Column(String)
author = Column(String)
@pytest.fixture(scope="session")
def db_connection() -> Connection:
db_url = "sqlite:///:memory:?check_same_thread=False"
engine = create_engine(db_url, pool_pre_ping=True)
with engine.connect() as connection:
Base.metadata.create_all(bind=connection)
@@ -73,10 +74,21 @@
def test_get_book_from_sql_repository_successfully(db_session: Session, a_book) -> None:
repo = SQLBookRepository(db_session)
created_book = repo.add(book=a_book)
book_from_db = repo.get(book_id=created_book.id)
assert book_from_db
assert isinstance(book_from_db, BookInDB)
assert created_book.id == book_from_db.id
assert a_book.title == book_from_db.title
assert a_book.author == book_from_db.author
+
+
+def test_get_list_of_books_successfully(db_session: Session) -> None:
+ repo = SQLBookRepository(db_session)
+ for book_id in range(0, 25):
+ book = Book(title=f"title {book_id}", author=f"author {book_id}")
+ repo.add(book=book)
+
+ books_from_db = repo.list()
+ assert books_from_db
+ assert len(books_from_db) == 5
Code Refactor
With all the tests going green, we can do the usual refactors to move the Book Model to a new file models.py
and the SQLBookRepository
class to the existing repositories.py
tests/models.py
+from uuid import uuid4
+
+from sqlalchemy import Column, String
+from sqlalchemy.ext.declarative import declarative_base
+
+Base = declarative_base()
+
+
+class BookModel(Base):
+ __tablename__ = 'books'
+
+ # It is not recommended to store uuid as str, but for these tests is ok
+ id = Column(String, primary_key=True, index=True, default=lambda x: str(uuid4()))
+ title = Column(String)
+ author = Column(String)
tests/repositories.py
@@ -1,14 +1,18 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from uuid import UUID
+from fastapi.encoders import jsonable_encoder
+from sqlalchemy.orm import Session
+
+from tests.models import BookModel
from tests.schemas import Book, BookInDB
class BookRepository(ABC):
@abstractmethod
def add(self, book: Book) -> BookInDB:
raise NotImplementedError
@abstractmethod
@@ -32,10 +36,31 @@
raise ValueError("This repository only accepts Book objects.")
book = BookInDB(**new_book.dict())
self.books[book.id] = book
return book
def get(self, book_id: UUID) -> Optional[BookInDB]:
return self.books.get(book_id, None)
def list(self, skip=0, offset=5) -> List[BookInDB]:
return list(self.books.values())[skip * offset:(skip + 1) * offset]
+
+
+class SQLBookRepository(BookRepository):
+ def __init__(self, db: Session):
+ self.db = db
+
+ def add(self, book: Book) -> BookInDB:
+ book_data = jsonable_encoder(book)
+ book = BookModel(**book_data)
+ self.db.add(book)
+ self.db.commit()
+ self.db.refresh(book)
+ return BookInDB(**book.__dict__)
+
+ def get(self, book_id: UUID) -> BookInDB:
+ book = self.db.query(BookModel).filter(BookModel.id == str(book_id)).first()
+ return BookInDB(**book.__dict__)
+
+ def list(self, skip=0, offset=5) -> List[BookInDB]:
+ books = self.db.query(BookModel).offset(skip).limit(offset).all()
+ return [BookInDB(**book.__dict__) for book in books]
tests/test_05_sqlalchemy_repository.py
@@ -1,56 +1,19 @@
from contextlib import closing
-from typing import List
-from uuid import UUID, uuid4
import pytest
-from fastapi.encoders import jsonable_encoder
-from sqlalchemy import Column, String
from sqlalchemy.engine import Connection, create_engine
-from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
-from tests.repositories import BookRepository
+from tests.models import Base
+from tests.repositories import SQLBookRepository
from tests.schemas import BookInDB, Book
-
-
-class SQLBookRepository(BookRepository):
- def __init__(self, db: Session):
- self.db = db
-
- def add(self, book: Book) -> BookInDB:
- book_data = jsonable_encoder(book)
- book = BookModel(**book_data)
- self.db.add(book)
- self.db.commit()
- self.db.refresh(book)
- return BookInDB(**book.__dict__)
-
- def get(self, book_id: UUID) -> BookInDB:
- book = self.db.query(BookModel).filter(BookModel.id == str(book_id)).first()
- return BookInDB(**book.__dict__)
-
- def list(self, skip=0, offset=5) -> List[BookInDB]:
- books = self.db.query(BookModel).offset(skip).limit(offset).all()
- return [BookInDB(**book.__dict__) for book in books]
-
-
-Base = declarative_base()
-
-
-class BookModel(Base):
- __tablename__ = 'books'
-
- # It is not recommended to store uuid as str, but for these tests is ok
- id = Column(String, primary_key=True, index=True, default=lambda x: str(uuid4()))
- title = Column(String)
- author = Column(String)
@pytest.fixture(scope="session")
def db_connection() -> Connection:
db_url = "sqlite:///:memory:?check_same_thread=False"
engine = create_engine(db_url, pool_pre_ping=True)
with engine.connect() as connection:
Base.metadata.create_all(bind=connection)
yield connection
Base.metadata.drop_all(bind=connection)
That's all for now.