sequenceDiagram participant WS as WebService participant Client as AttackClient participant Factory as AttackFactory participant Attack as Attack Business Object Note over Client: Method get_attack(id=1) called Note over Attack: FixedDamageAttack or<br/>PhysicalFormulaAttack or<br/>SpecialFormulaAttack Client->>WS: GET /attack/1 WS-->>Client: JSON <br/>{id:1,<br/> attack_type:"special attack",<br/> power:195, ...} Client->>Factory: instantiate_attack(<br/>type="special attack", <br/>id=1, ...) Factory->>Attack: Create Attack object Attack-->>Client: Attack instance returned Client-->>Client: Returns attack to caller (or None if not found)
Data Access Objet (DAO)
Before you start
😱 As you can see, the subject of this practical assignment is also quite extensive. Don’t let that scare you.
It combines comprehensive explanations and practical exercises to ensure maximum self-sufficiency.
Last TP
Last week, you primarily queried web services:
- Your application acted as a client ➡️ it sent HTTP requests to a web service
- The web service responded with JSON payloads
- You then converted that JSON into Python business objects
Objectives
Today, we’ll follow a similar principle. You’ll use the psycopg2 library to query a PostgreSQL database. You’ll execute SQL queries on the database, retrieve data in a format similar to JSON, and then convert it into Python business objects.
In this lab, you will:
- Review fundamental relational database concepts
- Implement the Data Access Object (DAO) design pattern. This pattern helps separate data persistence logic from your business logic, making your application more modular and easier to maintain
- Test your program’s functionality with reproducible unit tests
sequenceDiagram participant Dao as AttackDao participant DB as PostgreSQL Database participant Factory as AttackFactory participant Attack as Attack Business Object Note over Dao: method find_attack_by_id(id=1) called Dao->>DB: Connect to database Dao->>DB: SELECT *<br/> FROM attack<br/> WHERE id_attack=1 DB-->>Dao: {id:1,<br/>attack_type:"special attack",<br/>power:195,...} Dao->>Factory: instantiate_attack(type="special attack", id=1, ...) Factory->>Attack: Create Attack object Attack-->>Dao: Attack instance returned Dao-->>Dao: Return Attack (or None if not found)
1 Setting Up Your Environment
1.1 Launch VScode
You should not launch a standard VSCode-python service, but rather a customised version.
-
- It explains why we are going to use a customised service
1.2 Clone the repository
-
- refer to previous TP if you have not already created the fork
-
git switch tp4-base
- to ensure that everyone starts at the same level
You will use this repo but you won’t be able to push:
To check that everything is working:
-
pytest -v src/tests/test_business_object/
-
python src/__main__.py
If you are missing any packages, please follow the instructions in the README file.
In case of an error, first check that you have opened the correct root folder in your explorer, which should be ENSAI-2A-complement-info-TP.
1.3 Database connection
To connect to a database, you’ll need…, a database!
Now, we will store this connection information in the .env file:
.env
POSTGRES_HOST=
POSTGRES_PORT=5432
POSTGRES_DATABASE=
POSTGRES_USER= POSTGRES_PASSWORD=
These environment variables will be used by the DBConnection class to establish the connection with the database.
Finally, we’ll create the tables and insert some data:
This script will execute two SQL files:
- data/init_db.sql to create the tables
- data/pop_db.sql to insert data
2 Let’s warm up with SQL
-
- 🟢 a small green dot appears
Usually, the PostgreSQL database is detected automatically.
-
- See PostgreSQL service README to retrieve values
Write the following three queries (they will be used later in the lab):
- Tables are in the tp schema
- Write Beautiful SQL Queries
- Line Breaks: Start each major clause on a new line
- Keyword Alignment: Align your keywords vertically
- Uppercase Keywords: Use uppercase for all SQL keywords
SELECT *
FROM tp.pokemon p
WHERE p.hp > 80
ORDER BY p.speed DESC;
To help you, here is the schema of the tables and their relationships.
3 Data Access Objet (DAO)
3.1 Modelling
Let’s go back to the class diagram from TP2. Let’s limit ourselves to the “attack” part.
classDiagram class AbstractAttack{ <<abstract>> + DATABASE_TYPE_LABEL : str # _id : int # _power : int # _name : str # _description : str +compute_damage(APkm, APkm)$ int } class FixedDamageAttack{ + compute_damage(APkm,APkm ) int } class AbstractFormulaAttack{ <<abstract>> -get_attack_stat(APkm)$ float -get_defense_stat(APkm)$ float +compute_damage(APkm,APkm) int } class PhysicalAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } class SpecialAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } FixedDamageAttack--|>AbstractAttack AbstractFormulaAttack--|>AbstractAttack SpecialAttack--|>AbstractFormulaAttack PhysicalAttack--|>AbstractFormulaAttack
Since the attributes of our attacks are similar, we won’t code this functionality into the specific attack classes.
We could put the methods in AbstractAttack. That would actually work fine, as we’d have a single class with our methods to interact with the database. But we’re not going to do that!
And now you’re asking yourself:
😱 Why ???
And the answer is:
😛 Because it makes no sense!
Let’s go back to the phrase: low coupling, high cohesion. If we put all the persistence methods for our attacks in the AbstractAttack class, we’d have a class that:
- ✔️ Determines the behavior of attacks. This is exactly what we want (high cohesion).
- ❌ Determines how an attack is persisted
This is not an attack’s responsibility; it belongs to the chosen persistence system, or at least the intermediary between our objects and that system!
Personally, I don’t want to have to modify my AbstractAttack class just because I’ve decided to change the persistence management system. I risk modifying something I shouldn’t and creating regressions (i.e., making errors appear in code that didn’t have them before). And I’d like to limit the sources of errors.
Instead, we’re going to create a class that is responsible for only this one task: a DAO (Data Access Object) class. This is a technical class that will act as the interface between our stored data and our application. Here’s what that looks like in a class diagram.
classDiagram class AbstractAttack{ <<abstract>> + DATABASE_TYPE_LABEL : str # _id : int # _power : int # _name : str # _description : str +compute_damage(APkm, APkm)$ int } class FixedDamageAttack{ + compute_damage(APkm,APkm ) int } class AbstractFormulaAttack{ <<abstract>> -get_attack_stat(APkm)$ float -get_defense_stat(APkm)$ float +compute_damage(APkm,APkm) int } class PhysicalAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } class SpecialAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } FixedDamageAttack--|>AbstractAttack AbstractFormulaAttack--|>AbstractAttack SpecialAttack--|>AbstractFormulaAttack PhysicalAttack--|>AbstractFormulaAttack class AttackDao{ <<Singleton>> +create(AbstractAttack) AbstractAttack +find_by_id(int) AbstractAttack +find_all() List[AbstractAttack] +update(AbstractAttack) AbstractAttack +delete(AbstractAttack) bool } class DBConnection{ <<Singleton>> -__connection : Connection +connection() Connection } AbstractAttack<.. AttackDao: create/find/update AttackDao..> DBConnection: use to connect the database
3.2 Connection management and Singleton pattern
To connect to the database, we’ll use the Python library psycopg2. This library will establish the connection with the database, send our queries, and return the results.
However, we need to be careful with connection management, as we could quickly open hundreds of connections and degrade our application’s performance.
This is the job of the DBConnection class. Since it’s a singleton, there will be only one instance of this class in our entire application, and since it’s the one that connects to the database, we ensure the connection’s uniqueness.
This class is a purely technical solution, so feel free to reuse it for your project. It introduces an advanced concept in OOP: metaclasses.
A metaclass allows you to modify a class’s behavior at a deep level (for example, altering how objects are constructed by Python). Unless you have a specific passion for computer science, don’t spend time on this topic.
3.3 DAO and CRUD
If you look closely, our DAO’s methods resemble those of CRUD. This is normal, as these methods will store the SQL code.
The basic methods are generally:
find_all()
: which will return the entire tablefind_by_id()
: which returns a record based on its idcreate()
: which creates a new recorddelete()
: which deletes a recordupdate()
: which updates a record
These 5 methods are enough to communicate with your database. You can perform the rest of the processing in your Service classes (recommended). However, nothing prevents you from creating more complex methods (e.g., find_by_type_and_level_order_by_name_desc()).
Here is the general operation of one of the DAO methods:
a_dao_class.py
from dao.db_connection import DBConnection
class ADaoClass(metaclass=Singleton):
def a_dao_method(self, optional_param):
# Step 1: We get the connection using the DBConnection class.
with DBConnection().connection as connection:
# Step 2: From the connection, we create a cursor for the query.
with connection.cursor() as cursor:
# Step 3: We execute our SQL query.
"SQL query using an optional parameter")
cursor.execute(
# Step 4: We store the query result.
= cursor.fetchall()
res
if res:
# Step 5: We format the results into the desired shape (object, list...).
return something
The cursor object holds a pointer to the results of your query. This result isn’t yet on your machine; it’s still stored by the database. You have three methods to retrieve the results:
cursor.fetchone()
: Returns a single record as a dictionary.- If you call fetchone() again on the same cursor, you’ll get the next row.
cursor.fetchall()
: Returns all results as a list of dictionaries.- The dictionaries are the rows from the retrieved table.
- The dictionary keys are the retrieved columns.
- This method works well when you want all results at once and there are only a few of them. When you have millions of records, this will cause problems because:
- The data transfer over the internet will take time and block your application.
- Your application may be unable to handle such a large amount of data.
cursor.fetchmany(size)
: Returns the requested number of records as a list of dictionaries. This lets you control the volume of data you’re processing. If you call fetchmany(size) again on your cursor, you’ll retrieve the next set of rows (a pagination system).
Examples of returned data (cursor query: SELECT * FROM tp.attack;
):
A single dictionary
"id_attack": 2, "id_attack_type": 3, "power": 20, "accuracy": 100, "element": "Grass", "attack_name": "Absorb"} {
A list of dictionaries
["id_attack": 2, "id_attack_type": 3, "power": 20, "accuracy": 100, "element": "Grass", "attack_name": "Absorb"},
{"id_attack": 3, "id_attack_type": 2, "power": 40, "accuracy": 100, "element": "Rock", "attack_name": "Accelerock"},
{"id_attack": 4, "id_attack_type": 3, "power": 40, "accuracy": 100, "element": "Poison", "attack_name": "Acid"},
{
... ]
For more information: Pynative article
Just like with data from web services, the goal will be to convert this data into business objects.
4 First DAOs
4.1 DAO with Attack Types
-
- This will be useful for the next steps.
In the AttackDao class, create the following methods:
-
- returns the attack with the given ID or
None
if the attack is not found
- returns the attack with the given ID or
-
- returns a list of all attacks
- Bonus: add
limit
andoffset
parameters to this method
-
- updates an attack passed as a parameter and returns whether the modification was successful
- In the 2 find methods, to create our Attack business objects:
- We need to know the attack type’s name.
- However, this column doesn’t exist in the attack table.
- But perhaps you already have a query that does the job?
- Use the AttackFactory class to easily instantiate Attack business objects
4.2 Unit tests
You will run unit test using: pytest src/tests/test_dao/
test_attack_dao.py
from dao.attack_dao import AttackDao
class TestAttackDao:
def test_find_attack_by_id_ok(self):
# GIVEN
= 1
existing_id_attack
# WHEN
= AttackDao().find_attack_by_id(existing_id_attack)
attack
# THEN
assert attack is not None
assert attack.id == existing_id_attack
-
- any ideas to prevent this behaviour?
4.3 Pokemon DAO
Create the PokemonDAO class with the following methods:
-
- returns all Pokemon in the database
-
- returns a Pokemon with the given name
-
- Make a query by joining the
attack
andpokemon_attack
tables, filtering with the Pokemon’s ID - Generate the attacks from that data
- Make a query by joining the
5 DAO and Webservice
You will now make your database data accessible to other users by creating a REST webservice.
Add the following endpoints to the app.py
file:
app.py
@app.get("/attack/")
async def get_all_attacks():
"""GET /attack/"""
# TODO: retrieve attacks from the database using your DAO
return attacks
@app.get("/pokemon/")
async def get_all_pokemons(limit:int):
# TODO: retrieve pokemons from the database using your DAO
return pokemons
@app.get("/pokemon/{name}")
async def get_pokemon_by_name(name:str):
"""GET /pokemon/<name>"""
# TODO: retrieve the pokemon from the database using your DAO
return pokemon
To return objects, you will need to define classes inheriting from BaseModel
. You’ll find all the information you need in the FastAPI documentation.
Conclusion
In this lab, you implemented your first DAO.
It’s a technical class used to communicate with your data persistence system. The primary advantage of creating a separate class is to decouple the management of the persistence system and your application’s business logic as much as possible.
If you decide to stop using a relational database and switch to a NoSQL database instead, you’ll only need to change the DAO classes while still exposing the same methods.
End of the Lab
-
- if your VSCode service dies, your code will not be lost
- on Datalabs, there is no guarantee of the lifespan of your services
-
- to free up reserved resources