Desar l'estat de Biblioteca en un fitxer JSON, com a la lliçó anterior, funciona bé mentre
les dades són petites i només un procés les llegeix i les escriu alhora. Però una base de
dades relacional resol problemes que un fitxer no resol bé: consultes eficients sobre milions
de files, accés concurrent de diversos processos sense corrompre les dades, i integritat
garantida pel propi motor. Aquesta lliçó presenta ADO.NET, el conjunt de classes clàssic de
.NET per parlar directament amb una base de dades mitjançant SQL, fent servir SQLite com a
motor d'exemple. Veuràs com obrir una connexió, crear una taula, inserir dades de forma segura
davant la injecció SQL, i llegir-les de tornada —i per què, després de veure com de feixuc
resulta fer-ho tot a mà, la lliçó següent introdueix Entity Framework per automatitzar-ho.
Contingut
- Què és ADO.NET i quan es fa servir directament
- SQLite com a base de dades d'exemple i la cadena de connexió
SqliteConnection: obrir i tancar la connexió ambusing- Crear la taula
LlibresambExecuteNonQuery - Inserir dades de forma segura amb
SqliteParameter - Llegir dades amb
ExecuteReader - El catàleg de BiblioTech a SQLite, de principi a fi
- Què és ADO.NET i quan es fa servir directament
ADO.NET és el conjunt de classes de .NET, disponible des de les seves primeres versions, per comunicar-se amb una base de dades relacional escrivint SQL directament. Les seves peces principals són comunes a qualsevol motor (SQL Server, PostgreSQL, SQLite, MySQL...), encara que cadascun aporta la seva pròpia implementació mitjançant un proveïdor:
| Peça d'ADO.NET | Què representa | Classe per a SQLite |
|---|---|---|
| Connexió | Un canal obert cap a la base de dades | SqliteConnection |
| Ordre | Una sentència SQL a executar | SqliteCommand |
| Paràmetre | Un valor que se substitueix de forma segura dins del SQL | SqliteParameter |
| Lector | Un cursor de només lectura sobre els resultats d'un SELECT |
SqliteDataReader |
Escriure SQL a mà amb ADO.NET dona control total sobre la consulta exacta que s'executa, i és la base sobre la qual estan construïdes eines de més alt nivell, com Entity Framework (lliçó següent). En el dia a dia, la majoria d'aplicacions fan servir un ORM i rarament toquen ADO.NET directament; tot i així, entendre aquesta capa ajuda a comprendre què fa un ORM "per darrere", i continua sent l'opció adequada quan cal el màxim control sobre el SQL exacte que s'executa (per exemple, una consulta molt optimitzada per a un cas concret).
- SQLite com a base de dades d'exemple i la cadena de connexió
Aquesta lliçó fa servir SQLite, un motor de base de dades relacional que desa tota la base
de dades en un únic fitxer, sense necessitat d'instal·lar ni administrar un servidor a part
—ideal per aprendre i per a aplicacions petites. El paquet NuGet necessari és
Microsoft.Data.Sqlite:
Tota connexió comença amb una cadena de connexió, un text que descriu on i com connectar. Per a SQLite, n'hi ha prou d'indicar la ruta del fitxer:
Si bibliotech.db encara no existeix, SQLite el crea automàticament en el primer intent de
connexió; no cal cap pas d'instal·lació addicional més enllà del paquet NuGet.
SqliteConnection: obrir i tancar la connexió amb using
SqliteConnection: obrir i tancar la connexió amb usingSqliteConnection implementa IDisposable (recorda la lliçó d'Entrada/Sortida de Fitxers):
obrir una connexió reserva un recurs extern (un canal cap al fitxer de base de dades) que s'ha
de tancar sempre, fins i tot si alguna cosa falla a mig camí. Per això, igual que amb
StreamReader/StreamWriter, es declara sempre amb using:
using Microsoft.Data.Sqlite;
using SqliteConnection connexio = new SqliteConnection("Data Source=bibliotech.db");
connexio.Open();
Console.WriteLine($"Connexio oberta. Estat: {connexio.State}");
// connexio.Dispose() (que tanca la connexio) es crida automaticament al final del bloc/metodeconnexio.Open() és una crida explícita: crear l'objecte SqliteConnection no obre per si
sol la connexió, només la prepara; fins que no es crida Open(), no hi ha cap canal establert
cap a la base de dades.
- Crear la taula
Llibres amb ExecuteNonQuery
Llibres amb ExecuteNonQueryQualsevol sentència SQL que no retorni files (CREATE TABLE, INSERT, UPDATE, DELETE)
s'executa amb SqliteCommand.ExecuteNonQuery():
using SqliteConnection connexio = new SqliteConnection("Data Source=bibliotech.db");
connexio.Open();
using SqliteCommand ordreCrearTaula = connexio.CreateCommand();
ordreCrearTaula.CommandText =
"""
CREATE TABLE IF NOT EXISTS Llibres (
Isbn TEXT PRIMARY KEY,
Titol TEXT NOT NULL,
Autor TEXT NOT NULL,
Disponible INTEGER NOT NULL
)
""";
ordreCrearTaula.ExecuteNonQuery();
Console.WriteLine("Taula Llibres llesta.");connexio.CreateCommand() crea un SqliteCommand ja associat a aquesta connexió;
CommandText és el SQL a executar (aquí, amb una cadena literal multilínia de C# 11,
"""...""", còmoda per a sentències llargues). IF NOT EXISTS evita un error si el programa
s'executa diverses vegades i la taula ja existia d'una execució anterior. SQLite no té un
tipus BOOLEAN natiu; per convenció, Disponible es desa com a INTEGER (0/1), i el
propi proveïdor Microsoft.Data.Sqlite s'encarrega de convertir entre bool de C# i 0/1
de SQLite de forma automàtica.
- Inserir dades de forma segura amb
SqliteParameter
SqliteParameterL'error més perillós en construir SQL a mà és concatenar directament valors del programa dins del text SQL: obre la porta a la injecció SQL, una vulnerabilitat greu.
| Concatenació directa (perillós) | Paràmetres (SqliteParameter, correcte) |
|
|---|---|---|
| Codi | $"INSERT INTO Llibres VALUES ('{isbn}', ...)" |
"INSERT INTO Llibres VALUES ($isbn, ...)" + ordre.Parameters.AddWithValue("$isbn", isbn) |
Si isbn conté '; DROP TABLE Llibres; -- |
El SQL resultant executa ordres no previstes | El valor es tracta sempre com una dada literal, mai com a SQL |
| Seguretat | Vulnerable a injecció SQL | Segur davant la injecció SQL |
using SqliteCommand ordreInserir = connexio.CreateCommand();
ordreInserir.CommandText =
"INSERT OR REPLACE INTO Llibres (Isbn, Titol, Autor, Disponible) VALUES ($isbn, $titol, $autor, $disponible)";
ordreInserir.Parameters.AddWithValue("$isbn", "978-84-376-0495-4");
ordreInserir.Parameters.AddWithValue("$titol", "Rayuela");
ordreInserir.Parameters.AddWithValue("$autor", "Julio Cortazar");
ordreInserir.Parameters.AddWithValue("$disponible", true);
ordreInserir.ExecuteNonQuery();AddWithValue associa cada marcador ($isbn, $titol...) amb un valor concret de C#; el
proveïdor s'encarrega d'escapar-lo i enviar-lo per separat del text SQL, de manera que el
contingut del valor mai pot alterar l'estructura de la sentència. INSERT OR REPLACE és
una extensió de SQLite que insereix la fila si l'Isbn (clau primària) no existeix, o la
sobreescriu si ja existia —útil per no haver de distingir "alta" d'"actualització" en aquest
exemple. Regla sense excepcions: qualsevol valor que provingui del programa (entrada de
l'usuari, dades d'un altre sistema...) ha de viatjar sempre com a paràmetre, mai concatenat
directament en el text SQL.
- Llegir dades amb
ExecuteReader
ExecuteReaderLes sentències que sí que retornen files (SELECT) s'executen amb ExecuteReader(), que
retorna un SqliteDataReader: un cursor de només lectura i només cap endavant sobre el
resultat, que es recorre amb Read():
using SqliteCommand ordreSeleccionar = connexio.CreateCommand();
ordreSeleccionar.CommandText = "SELECT Isbn, Titol, Autor, Disponible FROM Llibres";
using SqliteDataReader lector = ordreSeleccionar.ExecuteReader();
while (lector.Read())
{
string isbn = lector.GetString(0);
string titol = lector.GetString(1);
string autor = lector.GetString(2);
bool disponible = lector.GetBoolean(3);
Console.WriteLine($"{titol} ({autor}) - ISBN {isbn} - Disponible: {disponible}");
}lector.Read() avança al següent registre i retorna false quan ja no en queden més,
exactament el mateix patró de bucle while que StreamReader.ReadLine() a la lliçó anterior.
GetString(index), GetBoolean(index), GetInt32(index)... accedeixen a cada columna per la
seva posició (començant a 0) en l'ordre indicat al SELECT; alternatives com
GetOrdinal("Titol") permeten obtenir aquesta posició a partir del nom de columna, més
resistent si l'ordre del SELECT canvia amb el temps.
- El catàleg de BiblioTech a SQLite, de principi a fi
Unint tot l'anterior, així es desa i es recupera el catàleg complet de llibres de BiblioTech
(per simplicitat, aquesta lliçó se centra en Llibre; una taula Revistes a part seguiria
exactament el mateix patró):
void DesarLlibresEnSqlite(List<Llibre> llibres, string cadenaConnexio)
{
using SqliteConnection connexio = new SqliteConnection(cadenaConnexio);
connexio.Open();
using SqliteCommand ordreCrearTaula = connexio.CreateCommand();
ordreCrearTaula.CommandText =
"""
CREATE TABLE IF NOT EXISTS Llibres (
Isbn TEXT PRIMARY KEY,
Titol TEXT NOT NULL,
Autor TEXT NOT NULL,
Disponible INTEGER NOT NULL
)
""";
ordreCrearTaula.ExecuteNonQuery();
foreach (Llibre llibre in llibres)
{
using SqliteCommand ordreInserir = connexio.CreateCommand();
ordreInserir.CommandText =
"INSERT OR REPLACE INTO Llibres (Isbn, Titol, Autor, Disponible) VALUES ($isbn, $titol, $autor, $disponible)";
ordreInserir.Parameters.AddWithValue("$isbn", llibre.Isbn);
ordreInserir.Parameters.AddWithValue("$titol", llibre.Titol);
ordreInserir.Parameters.AddWithValue("$autor", llibre.Autor);
ordreInserir.Parameters.AddWithValue("$disponible", llibre.Disponible);
ordreInserir.ExecuteNonQuery();
}
}
List<Llibre> CarregarLlibresDesDeSqlite(string cadenaConnexio)
{
List<Llibre> llibres = new List<Llibre>();
using SqliteConnection connexio = new SqliteConnection(cadenaConnexio);
connexio.Open();
using SqliteCommand ordreSeleccionar = connexio.CreateCommand();
ordreSeleccionar.CommandText = "SELECT Isbn, Titol, Autor, Disponible FROM Llibres";
using SqliteDataReader lector = ordreSeleccionar.ExecuteReader();
while (lector.Read())
{
string isbn = lector.GetString(0);
string titol = lector.GetString(1);
string autor = lector.GetString(2);
bool disponible = lector.GetBoolean(3);
Llibre llibre = new Llibre(titol, autor, isbn);
if (!disponible)
{
llibre.Prestar();
}
llibres.Add(llibre);
}
return llibres;
}List<Llibre> cataleg = new List<Llibre>
{
new Llibre("Rayuela", "Julio Cortazar", "978-84-376-0495-4"),
new Llibre("Ficcions", "Jorge Luis Borges", "978-84-376-0496-1")
};
string cadenaConnexio = "Data Source=bibliotech.db";
DesarLlibresEnSqlite(cataleg, cadenaConnexio);
List<Llibre> catalegRecuperat = CarregarLlibresDesDeSqlite(cadenaConnexio);
Console.WriteLine(catalegRecuperat.Count); // 2Compara la mida d'aquest codi amb el de la lliçó de Serialització: aquí ha calgut escriure a
mà el SQL de creació de taula, el d'inserció amb els seus paràmetres un a un, el de selecció,
i el mapeig manual columna a columna cap a Llibre. Funciona, i dona control total sobre el
SQL exacte, però és feina repetitiva que creixeria molt en afegir Socis i Prestecs (amb
les seves relacions entre taules). Aquesta feina repetitiva és exactament el que resol un
ORM (Object-Relational Mapper), el tema de la lliçó següent: Entity Framework.
Errors Comuns i Consells
- Concatenar valors directament en el SQL (
$"... WHERE Isbn = '{isbn}'"): obre la porta a la injecció SQL. Fes servir sempreSqliteParameter(viaAddWithValueoAdd) per a qualsevol valor que no formi part fixa de la sentència. - No tancar la connexió: sense
using, una connexió oberta i mai tancada esgota, amb el temps, el nombre de connexions disponibles cap a la base de dades. Declara sempreSqliteConnection,SqliteCommandiSqliteDataReaderambusing. - Accedir a una columna per una posició equivocada a
GetString/GetInt32/...: si l'ordre de les columnes delSELECTno coincideix amb els índexs usats en llegir elSqliteDataReader, s'obtenen dades de la columna equivocada sense que el compilador ho detecti.GetOrdinal("NomColumna")és més robust que confiar en índexs fixos si elSELECTpot canviar. - Oblidar
IF NOT EXISTSalCREATE TABLE: sense ell, executar el programa una segona vegada contra el mateix fitxer de base de dades llançaria un error perquè la taula ja existiria. - Consell: per a qualsevol aplicació real una mica més enllà d'un exemple d'aprenentatge, un ORM com Entity Framework (lliçó següent) redueix dràsticament aquest codi repetitiu i evita errors manuals de mapeig; reserva l'ADO.NET directe per a consultes molt concretes que necessitin el màxim control sobre el SQL exacte executat.
Exercicis
-
Escriu el codi per crear una taula
Socisa SQLite amb columnesId(INTEGER PRIMARY KEY) iNom(TEXT NOT NULL), fent servirCREATE TABLE IF NOT EXISTSiExecuteNonQuery. -
Escriu el codi per inserir un
Socia la taulaSocisde l'exercici anterior, fent servirSqliteParameter(ambAddWithValue) per als dos valors, mai concatenació directa. -
Escriu una funció que llegeixi tots els registres de la taula
SocisambExecuteReader, reconstrueixi unSociper cada fila, i retorni unList<Soci>amb tots ells.
Solucions
using SqliteCommand ordre = connexio.CreateCommand();
ordre.CommandText =
"""
CREATE TABLE IF NOT EXISTS Socis (
Id INTEGER PRIMARY KEY,
Nom TEXT NOT NULL
)
""";
ordre.ExecuteNonQuery();
Soci soci = new Soci(1, "Anna Martinez");
using SqliteCommand ordre = connexio.CreateCommand();
ordre.CommandText = "INSERT OR REPLACE INTO Socis (Id, Nom) VALUES ($id, $nom)";
ordre.Parameters.AddWithValue("$id", soci.Id);
ordre.Parameters.AddWithValue("$nom", soci.Nom);
ordre.ExecuteNonQuery();
List<Soci> CarregarSocisDesDeSqlite(SqliteConnection connexio)
{
List<Soci> socis = new List<Soci>();
using SqliteCommand ordre = connexio.CreateCommand();
ordre.CommandText = "SELECT Id, Nom FROM Socis";
using SqliteDataReader lector = ordre.ExecuteReader();
while (lector.Read())
{
int id = lector.GetInt32(0);
string nom = lector.GetString(1);
socis.Add(new Soci(id, nom));
}
return socis;
}
Conclusió
En aquesta lliçó has fet servir l'ADO.NET clàssic per parlar directament amb una base de dades
SQLite: obrir una connexió amb SqliteConnection, executar sentències sense resultat amb
ExecuteNonQuery, inserir dades de forma segura davant la injecció SQL amb SqliteParameter,
i llegir resultats amb ExecuteReader. El catàleg de llibres de BiblioTech ja pot viure en una
base de dades relacional real, no només en un fitxer de text o JSON.
També has comprovat, de primera mà, com de repetitiu resulta aquest enfocament: SQL escrit a mà per a cada operació, paràmetres afegits un a un, i un mapeig manual columna a columna cap a cada propietat de l'objecte. La lliçó següent, Entity Framework, introdueix un ORM que automatitza gairebé tota aquesta feina: mapeja les classes del domini de BiblioTech a taules, genera el SQL per tu, i permet consultar amb LINQ —la mateixa eina que ja coneixes des del Mòdul 4— en lloc de SQL escrit a mà.
Curs de Programació en C#
Mòdul 1: Introducció al C#
- Introducció al C#
- Configuració de l'Entorn de Desenvolupament
- Programa Hola Món
- Sintaxi i Estructura Bàsica
- Variables i Tipus de Dades
- Arrays i Cadenes de Text
Mòdul 2: Estructures de Control
Mòdul 3: Programació Orientada a Objectes
- Classes i Objectes
- Mètodes
- Constructors i Destructors
- Herència
- Polimorfisme
- Encapsulació
- Abstracció
- Structs i Records: Tipus per Valor i per Referència
Mòdul 4: Conceptes Avançats de C#
- Interfícies
- Delegats i Esdeveniments
- Pattern Matching i Característiques Modernes de C#
- Genèrics
- Col·leccions
- LINQ (Consulta Integrada en el Llenguatge)
- Programació Asíncrona
Mòdul 5: Treballant amb Dades
- Entrada/Sortida de Fitxers
- Serialització
- Connectivitat amb Bases de Dades
- Entity Framework
- Treball amb JSON i Consum d'APIs REST
Mòdul 6: Temes Avançats
- Reflexió
- Atributs
- Programació Dinàmica
- Gestió de Memòria i Recollida d'Escombraries
- Multifil i Programació Paral·lela
Mòdul 7: Construcció d'Aplicacions
Mòdul 8: Bones Pràctiques i Patrons de Disseny
- Estàndards de Codificació i Bones Pràctiques
- Patrons de Disseny
- Injecció de Dependències i Inversió de Control
- Proves Unitàries
- Revisió i Refactorització de Codi
