In questo post esploreremo il nostro progetto open source per capire come stabilire una connessione a un database in SQLAlchemy, e come sfruttare gli strong types per la progettazione di metodi.
Nel nostro post precedente, abbiamo discusso come codificare modelli di dati in SQLAlchemy e Python. Oggi vedremo come creare una connessione ad un database in SQLAlchemy, e come utilizzare strong types per progettare interfacce più gestibili e sicure.
In sem
, le operazioni sul database vengono gestite dalla classe CRUDHandler
, un wrapper attorno alle necessarie funzioni SQLAlchemy, definito nel modulo modules/crud_handler.py
. L’acronimo CRUD sta per “Create, Read, Update, Delete“, il tipico insieme di operazioni da eseguire su un database.
crud_handler.py
contiene anche la definizione dell’eccezione CRUDHandlerError
, utilizzata da CRUDHandler
per segnalare errori nella manipolazione del database.
# modules/crud_handler.py
1 """Class mediating CRUD operations on the DB.
2
3 Classes
4 -----------------------
5 CRUDHandlerError
6 Exception raised for errors in DB operations.
7 CRUDHandler
8 Class mediating CRUD operations.
9
10 Functions
11 -----------------------
12 CRUDHandlerContext()
13 Manage context for CRUDHandler.
14 """
L’Eccezione CRUDHandlerError
CRUDHandlerError
è un’eccezione che verrà sollevata (insieme a un messaggio esplicativo) quando CRUDHandler
incontrerà un errore. Creare eccezioni ad hoc per classi definite dall’utente è utile per distinguerle da quelle che altre componenti del programma potrebbero sollevare.
# modules/crud_handler.py
40 from contextlib import contextmanager
41
42 from pydantic import ValidationError
43
44 from sqlalchemy import select
45 from sqlalchemy import func
46 from sqlalchemy import and_
47 from sqlalchemy.sql import text
48
49 from modules.models import Expense
50 from modules.session import init_session
51 from modules.schemas import ExpenseAdd
52 from modules.schemas import ExpenseUpdate
53 from modules.schemas import QueryParameters
<---------------------------snip--------------------------->
56 class CRUDHandlerError(Exception):
57 """Exception raised for errors in DB operations."""
La procedura standard consiste nel definire un tipo derivato della classe di default Exception
. Anche senza aggiungere metodi e attributi, l’eccezione funzionerà come quelle predefinite (ad esempio, accettando un messaggio di errore come stringa durante la costruzione, come vedremo di seguito).
Connessione a Database
Protocollo di Inizializzazione
Possiamo ora esaminare la classe CRUDHandler
, a partire dalla sua inizializzazione, che deve impostare tre elementi per avere una connessione operativa a un database.
Innanzitutto è necessario impostare una stringa di connessione. Questa sarà una stringa contenente le informazioni necessarie affinché SQLAlchemy si connetta al database o lo crei ex novo.
Il suo primo componente è solitamente un driver, con informazioni sul database service sottostante e sui componenti utilizzati per connettervisi. La stringa conterrà quindi i dettagli del database, in un formato predefinito. Questi saranno le credenziali dell’utente, la posizione del database service (ad esempio, host HTTP e porta a cui connettersi per gli scambi di dati) e il nome del database.
Il secondo passo è creare un
SQLAlchemy, un oggetto a basso livello connesso al database e utilizzato per eseguire operazioni a basso livello (non-CRUD). Queste includono, ad esempio, la creazione del database e delle tabelle associate.engine
Infine, la procedura di inizializzazione creerà una
SQLAlchemy, un costrutto ad alto livello utilizzato per eseguire operazioni CRUD. Questo oggetto è basato su un Session
engine
, e una sua istanza sarà contenuta come attributo nella classe CRUDHandler
.
Funzione di Inizializzazione
Ho inserito queste operazioni nella funzione init_session()
del modulo modules/session.py
. Ciò consentirà di cambiare più semplicemente i parametri di connessione (come vedremo quando opereremo con i container).1
# modules/session.py
32 from sqlalchemy import create_engine
33 from sqlalchemy.orm import Session
34 from sqlalchemy_utils import database_exists
35 from sqlalchemy_utils import create_database
36
37 from modules.models import Base
38
39
40 def init_session(database: str) -> Session:
41 """Init connection to specified DB and write schema.
42
43 Parameters
44 -----------------------
45 database : str
46 Database name to connect to.
47
48 Returns
49 -----------------------
50 sqlalchemy.orm.Session
51 The initialized Session.
52 """
53 DRIVER = "postgresql+psycopg"
54 USER = "postgres"
55 PASSWORD = ""
56 HOST = "localhost"
57 PORT = "5432"
58
59 DB = f"{DRIVER}://{USER}:{PASSWORD}@{HOST}:{PORT}/{database}"
60 if not database_exists(DB):
61 create_database(DB)
62
63 engine = create_engine(DB)
64 # Building schema
65 Base.metadata.create_all(engine)
66
67 return Session(bind=engine)
psycopg
è una delle possibili interfacce per connettersi a un database PostgreSQL, utilizzando il driver psycopg3
. Il resto della stringa di connessione configura un database per l’utente postgres
(il nome utente predefinito nella maggior parte delle installazioni PostgreSQL), senza password, e indica a SQLAlchemy di inviare i dati all’indirizzo HTTP localhost
sulla porta 5432
(la posizione predefinita in cui il servizio PostgreSQL può essere contattato).
Le funzioni database_exists()
e create_database()
di SQLAlchemy permettono di testare l’esistenza di un database e di crearlo secondo la stringa di connessione. La funzione di inizializzazione crea quindi un oggetto engine
in base alla stringa di connessione.
Questo oggetto viene immediatamente utilizzato per creare le tabelle necessarie a contenere i dati seguendo il nostro modello specificato. Questo viene fatto chiamando il metodo create_all()
dell’oggetto SQLAlchemy
contenuto in MetaData
(nell’attributo di nome Base
metadata
). Tutti i modelli ereditano (tramite
) accesso a questo attributo, consentendogli di creare tabelle per contenere tutti i modelli creati.Base
Infine, l’engine
viene passato come argomento per creare un oggetto Session
, che verrà utilizzato per eseguire effettivamente le operazioni CRUD.
CRUDHandler utilizzerà init_session()
per inizializzare il suo attributo interno session
. Avrà bisogno anche di una funzione per chiudere la sessione (cosa che può essere fatta chiamando il metodo Session.close()
), da invocare una volta completate le operazioni sul database.
# modules/crud_handler.py
60 class CRUDHandler:
61 """Class mediating CRUD operations.
62
63 Attributes
64 -----------------------
65 session : sqlalchemy.Orm.Session
66 Session connected to the specified DB.
67
68 Public methods
69 -----------------------
70 __init__()
71 Initialize class instance.
72 close()
73 Close DB connection.
74 add()
75 Add expense to the DB.
76 query()
77 Return expenses matching specified filters.
78 summarize()
79 Summarize expenses matching specified filters.
80 update()
81 Update expense selected by ID.
82 remove()
83 Remove selected expenses from the DB.
84 erase()
85 Remove all expenses from the DB.
86 load()
87 Append the contents of a CSV file to the database.
88 save()
89 Save the current contents of the DB to a CSV file.
90 """
91
92 def __init__(self, database: str):
93 """Initialize class instance.
94
95 Parameters
96 -----------------------
97 database : str
98 Database name to connect to.
99 """
100 self.session = init_session(database)
101
102 def close(self):
103 """Close DB connection."""
104 self.session.close()
Context Manager
In classi come
, che gestiscono risorse, è utile definire un context manager, che creerà un accesso alla risorsa ed eseguirà cleanup al termine dell’esecuzione (anche se viene sollevata un’eccezione). Ho implementato questa funzionalità nella funzione CRUDHandler
CRUDHandlerContext()
(che non è un metodo di
), definita alla fine di CRUDHandler
modules/crud_handler.py
.
# modules/crud_handler.py
334 @contextmanager
335 def CRUDHandlerContext(database: str) -> CRUDHandler:
336 """Manage context for CRUDHandler.
337
338 Parameters
339 -----------------------
340 database : str
341 Database name.
342
343 Yields
344 -----------------------
345 CRUDHandler
346 The context-managed CRUDHandler.
347 """
348 ch = CRUDHandler(database)
349 try:
350 yield ch
351 finally:
352 ch.close()
Il decoratore contextlib.contextmanager
consente di creare un context manager da un generatore, che restituisce (tramite
) l’oggetto creato, senza dover scrivere una classe generatore con metodi yield
__enter__()
e __exit__()
non banali. L’istruzione
dovrebbe essere inclusa in un blocco yield
, mentre il blocco try
è responsabile per il cleanup.finally
In pratica, il context manager qui sopra verrà chiamato con una sintassi del tipo
with CRUDHandlerContext(<nome del database>) as <handle>:
<operazioni che usano l'oggetto <handle> di tipo CRUDHandler>
che garantisce il cleanup quando il contesto viene chiuso. Nei nostri prossimi post vedremo applicazioni concrete di questa funzione.
Strong Types
Siamo quasi pronti ad iniziare ad esplorare le operazioni CRUD. Prima di procedere, tuttavia, dobbiamo comprendere il concetto di strong type, un potente paradigma di programmazione per la progettazione di funzioni, ampiamente utilizzato in sem
.
L’idea fondamentale è creare tipi per aggregare i parametri accettati da una funzione in un singolo oggetto (in modo simile a quanto si fa in C, dove struct
s vengono utilizzate quando più valori devono essere restituiti simultaneamente da una funzione). Questo idioma consente una maggiore sicurezza, diminuendo la possibilità di confusione nell’assegnazione di valori ai parametri e semplificando la loro convalida.
In sem
, ho definito i tipi utilizzati dalle funzioni CRUD in modules/schemas.py
.
# modules/schemas.py
1 """Types for CRUD operations.
2
3 Classes
4 -----------------------
5 ExpenseBase
6 Base expense class.
7 ExpenseAdd
8 Derived expense class for insertion operations.
9 QueryParameters
10 Strong type for query parameters.
11 ExpenseRead
12 Derived expense class for query operations.
13 ExpenseUpdate
14 Container for data to update existing expenses with.
15 """
Esamineremo innanzitutto ExpenseAdd
, il tipo utilizzato nelle operazioni di “creazione” in CRUDHandler. L’ho progettato in modo che il metodo associato in CRUDHandler potesse avere la firma
add(self, data: ExpenseAdd)
senza bisogno di ulteriori argomenti.
ExpenseAdd
deve dunque contenere tutte le informazioni necessarie per creare un oggetto di tipo Expense
, ovvero tutti i campi tranne l’ID (che verrà assegnato automaticamente dal database). Poiché questi campi verranno utilizzati anche da altri strong types, li ho incapsulati in una classe base dedicata ExpenseBase
.
# modules/schemas.py
39 import datetime
40 from typing import Optional
41
42 from pydantic import BaseModel
43 from pydantic import Field
44
45
46 class ExpenseBase(BaseModel):
47 """Base expense class.
48
49 Attributes
50 -----------------------
51 date : datetime.date
52 Date of the expense.
53 type : str
54 Low-level group of the expense.
55 category : Optional[str]
56 High-level group of the expense. Default is `None` -> "".
57 amount : float
58 Amount of the expense.
59 description : str
60 Description of the expense.
61 """
62
63 date: datetime.date = Field(description="Date of the expense.")
64 type: str = Field(description="Low-level group of the expense.")
65 category: str = Field(
66 default="",
67 description="High-level group of the expense. Default is
''.",
68 )
69 amount: float = Field(description="Amount of the expense.")
70 description: str = Field(description="Description of the
expense.")
71
72
73 class ExpenseAdd(ExpenseBase):
74 """Type for insertion operations."""
ExpenseBase
eredita da
, un tipo definito nel modulo BaseModel
. Si tratta essenzialmente di una dataclass con convalida incorporata per i valori dei campi, che supporta tutti i tipi builtin di Python: ciò offre una forte sicurezza durante l’inizializzazione, a costo zero per lo sviluppatore.pydantic
BaseModel
provvede anche un costruttore di default con keyword arguments
instance = ExpenseAdd(date=..., type=..., ...)
e funzioni di serializzazione, ed è fortemente integrato con FastAPI, il framework API che ho scelto in sem
.
Nei BaseModel
di pydantic
, il costrutto Field
viene utilizzato per fornire annotazioni di tipo avanzato, che danno informazioni al di là del semplice tipo di dati. Qui specifichiamo una descrizione per i campi, che apparirà nella documentazione, nonché valori predefiniti, ove esistenti.
Perché la Duplicazione dei Tipi ?
ExpenseAdd
somiglia molto al tipo Expense
che abbiamo visto nel nostro post precedente, e che definisce il modello dei dati. Uno si potrebbe chiedere perché ho ridefinito una copia quasi identica di un tipo esistente, una domanda che ha una risposta non banale.
Innanzitutto, in SQLAlchemy le istanze del tipo modello rimangono connesse ai dati associati nel database, e le modifiche applicate al primo possono essere esportate al secondo. Anche se non abbiamo ancora visto questo idioma, esso giocherà un ruolo cruciale nel metodo CRUD di “Update”.
A causa di questa funzionalità, lavorare con istanze del modello al di fuori delle operazioni CRUD può portare a comportamenti e cambiamenti imprevisti nel database, il che costituisce una buona ragione per usare tipi di dati indipendenti, che isolano i dati contenuti.
Inoltre, il modello dei dati (che eredita da SQLAlchemy.orm.
) non ha gli stessi attributi e metodi dei nostri strong types (che ereditano da DeclarativeBase
pydantic.
). Ciò fa sì che perdano, ad esempio, la convalida in fase di costruzione, le funzioni di serializzazione…BaseModel
Infine, sia il nostro modello che i nostri strong types utilizzano annotazioni per fornire informazioni aggiuntive sui campi. Tuttavia, le annotazioni del primo si basano su ciò che SQLAlchemy richiede per creare il database, e sono diverse dalle annotazioni (in genere più utili per la documentazione e lo sviluppatore) degli strong types.
Dunque, anche se è necessaria un po’ di duplicazione, avere un gruppo di strong types indipendenti con validazione è in generale un’idea migliore.
Nelle prossime puntate
Abbiamo imparato come stabilire una connessione ad un database in SQLAlchemy, e come utilizzare strong types per disegnare metodi sicuri e facili da mantenere.
Nel prossimo post di questa serie esploreremo la classe
per capire come aggiungere, cercare, aggiornare e rimuovere dati.CRUDHandler
Autore: Adriano Angelone
Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.
In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.
- Dall’uscita di questo post, il file
session.py
è stato modificato per permettere l’esecuzione del programma in un ambiente containerizzato. La versione mostrata qui è ancora disponibile sulla branchlegacy
del repositorysem
. ↩︎