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

  1. Què és ADO.NET i quan es fa servir directament
  2. SQLite com a base de dades d'exemple i la cadena de connexió
  3. SqliteConnection: obrir i tancar la connexió amb using
  4. Crear la taula Llibres amb ExecuteNonQuery
  5. Inserir dades de forma segura amb SqliteParameter
  6. Llegir dades amb ExecuteReader
  7. El catàleg de BiblioTech a SQLite, de principi a fi

  1. 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).

  1. 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:

dotnet add package 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:

string cadenaConnexio = "Data Source=bibliotech.db";

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.

  1. SqliteConnection: obrir i tancar la connexió amb using

SqliteConnection 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/metode

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

  1. Crear la taula Llibres amb ExecuteNonQuery

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

  1. Inserir dades de forma segura amb SqliteParameter

L'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.

  1. Llegir dades amb ExecuteReader

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

  1. 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); // 2

Compara 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 sempre SqliteParameter (via AddWithValue o Add) 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 sempre SqliteConnection, SqliteCommand i SqliteDataReader amb using.
  • Accedir a una columna per una posició equivocada a GetString/GetInt32/...: si l'ordre de les columnes del SELECT no coincideix amb els índexs usats en llegir el SqliteDataReader, 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 el SELECT pot canviar.
  • Oblidar IF NOT EXISTS al CREATE 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

  1. Escriu el codi per crear una taula Socis a SQLite amb columnes Id (INTEGER PRIMARY KEY) i Nom (TEXT NOT NULL), fent servir CREATE TABLE IF NOT EXISTS i ExecuteNonQuery.

  2. Escriu el codi per inserir un Soci a la taula Socis de l'exercici anterior, fent servir SqliteParameter (amb AddWithValue) per als dos valors, mai concatenació directa.

  3. Escriu una funció que llegeixi tots els registres de la taula Socis amb ExecuteReader, reconstrueixi un Soci per cada fila, i retorni un List<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#

Mòdul 2: Estructures de Control

Mòdul 3: Programació Orientada a Objectes

Mòdul 4: Conceptes Avançats de C#

Mòdul 5: Treballant amb Dades

Mòdul 6: Temes Avançats

Mòdul 7: Construcció d'Aplicacions

Mòdul 8: Bones Pràctiques i Patrons de Disseny

Mòdul 9: Projecte Final

© Copyright 2026. Tots els drets reservats