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.

In SQLAlchemy, una connessione a un database si stabilisce specificando una stringa di connessione, che contiene l'informazione necessaria. La stringa è usata per inizializzare un oggetto di tipo "engine", che a sua volta permette di costruire una "Session", che performerà operazioni sul database. Questo tipo di oeprazioni può essere eseguito in maniera robusta e sicura usando strong types nella costruzione delle relative funzioni.

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 engine 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.

Infine, la procedura di inizializzazione creerà una Session SQLAlchemy, un costrutto ad alto livello utilizzato per eseguire operazioni CRUD. Questo oggetto è basato su un 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 MetaData contenuto in Base (nell’attributo di nome metadata). Tutti i modelli ereditano (tramite Base) accesso a questo attributo, consentendogli di creare tabelle per contenere tutti i modelli creati.

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 CRUDHandler, 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 CRUDHandlerContext() (che non è un metodo di CRUDHandler), definita alla fine di 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 yield) l’oggetto creato, senza dover scrivere una classe generatore con metodi __enter__() e __exit__() non banali. L’istruzione yield dovrebbe essere inclusa in un blocco try, mentre il blocco finally è responsabile per il cleanup.

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 structs 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 BaseModel, un tipo definito nel modulo pydantic. 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.

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.DeclarativeBase) non ha gli stessi attributi e metodi dei nostri strong types (che ereditano da pydantic.BaseModel). Ciò fa sì che perdano, ad esempio, la convalida in fase di costruzione, le funzioni di serializzazione

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 CRUDHandler per capire come aggiungere, cercare, aggiornare e rimuovere dati.


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.

  1. 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 branch legacy del repository sem. ↩︎