19  Gestió de bases de dades amb JDBC

Objectius

  • Explicar què és JDBC i quin paper hi juga el driver.
  • Obrir i tancar una connexió a una base de dades amb DriverManager i Connection.
  • Escriure SQL bàsic: CREATE TABLE, SELECT, INSERT, UPDATE, DELETE.
  • Distingir Statement de PreparedStatement i saber per què el segon és més segur.
  • Recórrer resultats amb ResultSet.
  • Usar SQLite com a base de dades d’exemple.
  • Organitzar l’accés a dades amb el patró DAO.

Al capítol anterior vam fer sobreviure les dades amb serialització. Ara farem el salt a una de les opcions utilitzada a nivell empresarial: guardar la informació en una base de dades relacional i accedir-hi des de Java amb JDBC.

19.1 Bases de dades relacionals

Una base de dades relacional organitza la informació en taules.

Cada taula té columnes (els camps) i files (els registres). És com un full de càlcul, però amb regles estrictes de tipus i relacions.

erDiagram
    LLIBRE {
        int id PK
        string titol
        string isbn
        int any_publicacio
        int disponible
    }

Per parlar amb la base de dades fem servir SQL (Structured Query Language), un llenguatge estàndard per crear taules, inserir dades i consultar-les.

19.2 Què és JDBC?

JDBC (Java Database Connectivity) és l’API estàndard de Java per connectar-se a bases de dades.

La gràcia és que ofereix les mateixes classes i mètodes (Connection, Statement, ResultSet…) independentment de la base de dades utilitzada.

La peça que tradueix aquestes crides al dialecte concret de cada base de dades és el driver.

flowchart LR
    A["Programa Java"] --> B["API JDBC<br/>(java.sql)"]
    B --> C["Driver SQLite"]
    B --> D["Driver MySQL"]
    B --> E["Driver PostgreSQL"]
    C --> C1[("SQLite")]
    D --> D1[("MySQL")]
    E --> E1[("PostgreSQL")]

ConsellPer què és útil?

Al programar amb JDBC i no direcament contra una base de dades concreta, pots canviar de SGBD canviant només el driver i l’URL de connexió, sense reescriure tota la lògica.

Aquesta és la mateixa idea de programar contra interfícies.

19.3 SQLite

Una opció senzilla per començar és SQLite ja que:

  • La base de dades sencera és un únic fitxer (biblioteca.db).
  • No cal instal·lar cap servidor: el motor va dins la mateixa aplicació.
  • Fa servir SQL estàndard.

Java ens proporciona les classes Connection, DriverManager, PreparedStatement i ResultSet però no els drivers en sí.

Per tant, necessitem una biblioteca externa. Les opcions que tenim per utilitzar-la són:

  1. Descarregar el .jar manualment i importar-lo al projecte (classpath).
  2. Utilitzar Maven.

Per una introducció al tema, qualsevol de les dues en serveix, però l’estàndard a dia d’avui o la recomanació és Maven.

Per això, hem d’ afegir el driver com a dependència al pom.xml:

<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.46.0.0</version>
</dependency>

19.4 La connexió

Tot comença obtenint una Connection. La demanem al DriverManager passant-li una URL de connexió, l’adreça de la base de dades.

import java.sql.*;

public class ProvaConnexio {
    public static void main(String[] args) {
        // Per a SQLite, l'URL és jdbc:sqlite: + ruta del fitxer
        String url = "jdbc:sqlite:biblioteca.db";

        try (Connection conn = DriverManager.getConnection(url)) {
            System.out.println("Connexió establerta correctament!");
        } catch (SQLException e) {
            System.out.println("Error de connexió: " + e.getMessage());
        }
    }
}
ImportantUtilitza sempre try-with-resources

Connection, Statement, PreparedStatement i ResultSet són recursos que cal tancar.

Si no els tanques, deixes connexions obertes i pots esgotar-les.

El try-with-resources (try (...) { }) els tanca automàticament encara que hi hagi una excepció. Fes-lo servir sempre.

L’URL de connexió canvia segons el SGBD:

SGBD URL de connexió (exemple)
SQLite jdbc:sqlite:biblioteca.db
MySQL jdbc:mysql://localhost:3306/biblioteca
PostgreSQL jdbc:postgresql://localhost:5432/biblioteca

19.5 SQL bàsic des de Java

Recorrem les operacions fonamentals. En conjunt, les operacions de crear, llegir, actualitzar i esborrar es coneixen com a CRUD (Create, Read, Update, Delete).

flowchart LR
    C["INSERT<br/>(Create)"] --- R["SELECT<br/>(Read)"] --- U["UPDATE<br/>(Update)"] --- D["DELETE<br/>(Delete)"]

Crear una taula

Les sentències que no retornen files (crear taules, inserir, actualitzar, esborrar) s’executen amb executeUpdate (o execute per a DDL).

try (Connection conn = DriverManager.getConnection("jdbc:sqlite:biblioteca.db");
     Statement stmt = conn.createStatement()) {

    String sql = """
        CREATE TABLE IF NOT EXISTS llibre (
            id             INTEGER PRIMARY KEY AUTOINCREMENT,
            titol          TEXT    NOT NULL,
            isbn           TEXT    UNIQUE,
            any_publicacio INTEGER,
            disponible     INTEGER DEFAULT 1
        )
        """;
    stmt.execute(sql);
    System.out.println("Taula creada (o ja existent).");

} catch (SQLException e) {
    System.out.println("Error: " + e.getMessage());
}

IF NOT EXISTS evita l’error si la taula ja existeix. PRIMARY KEY AUTOINCREMENT fa que l’id es generi sol.

19.5.1 Inserir dades

Aquí introduïm ja la manera correcta de passar valors: amb un PreparedStatement.

Els signes ? són paràmetres que omplim després.

String sql = "INSERT INTO llibre (titol, isbn, any_publicacio) VALUES (?, ?, ?)";

try (Connection conn = DriverManager.getConnection("jdbc:sqlite:biblioteca.db");
     PreparedStatement ps = conn.prepareStatement(sql)) {

    ps.setString(1, "El nom del vent");                     // Primer ?
    ps.setString(2, "978-8401352836");                      // Segon ?
    ps.setInt(3, 2007);                                     // Tercer ?

    int files = ps.executeUpdate();                         // Retorna el número de files afectades
    System.out.println(files + " fila inserida.");

} catch (SQLException e) {
    System.out.println("Error en inserir: " + e.getMessage());
}
ConsellEls índexs comencen per 1

A JDBC, els paràmetres (setString(1, ...)) i les columnes del ResultSet es numeren des de l’1, no des del 0 com als arrays.

És una de les poques coses de Java que comencen a comptar per 1.

19.5.2 Consultar dades

Les consultes que retornen files s’executen amb executeQuery, que ens dona un ResultSet: un cursor que recorrem fila a fila amb next().

String sql = "SELECT id, titol, any_publicacio FROM llibre WHERE disponible = 1";

try (Connection conn = DriverManager.getConnection("jdbc:sqlite:biblioteca.db");
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {

    while (rs.next()) {                             // Avança a la fila següent
        int id = rs.getInt("id");
        String titol = rs.getString("titol");
        int any = rs.getInt("any_publicacio");
        System.out.println(id + " - " + titol + " (" + any + ")");
    }

} catch (SQLException e) {
    System.out.println("Error en consultar: " + e.getMessage());
}

flowchart TD
    A["executeQuery()"] --> B{"rs.next()?"}
    B -->|"cert"| C["llegir columnes<br/>getInt/getString..."] --> B
    B -->|"fals"| D["fi del recorregut"]

AlertaError típic: llegir abans de next()

Un ResultSet acabat de crear està col·locat abans de la primera fila.

Si crides rs.getString(...) sense haver cridat rs.next() primer, obtindràs una SQLException.

Sempre while (rs.next()) { ... } o if (rs.next()) { ... }.

19.5.3 Actualitzar i esborrar

Segueixen el mateix patró que INSERT: PreparedStatement + executeUpdate.

// Marcar un llibre com a no disponible (prestat)
String sqlUpdate = "UPDATE llibre SET disponible = 0 WHERE id = ?";
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:biblioteca.db");
     PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
    ps.setInt(1, 3);
    int files = ps.executeUpdate();
    System.out.println(files + " llibre actualitzat.");
} catch (SQLException e) {
    System.out.println("Error: " + e.getMessage());
}

// Esborrar un llibre
String sqlDelete = "DELETE FROM llibre WHERE id = ?";
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:biblioteca.db");
     PreparedStatement ps = conn.prepareStatement(sqlDelete)) {
    ps.setInt(1, 3);
    ps.executeUpdate();
} catch (SQLException e) {
    System.out.println("Error: " + e.getMessage());
}

Comparativa entre Statement i PreparedStatement

Tots dos executen SQL, però tenen diferències importants.

Aspecte Statement PreparedStatement
Com passa els valors Concatenant text a la SQL Amb paràmetres ? i setXxx
Seguretat Vulnerable a injecció SQL Protegit: els valors s’escapen sols
Rendiment Es compila cada cop Es precompila (millor si es repeteix)
Tipus de dades Ho poses tot com a text setInt, setString, setDouble
Quan usar-lo SQL fix sense valors variables Sempre que hi hagi valors variables


Per evitar la injecció de SQL cal utilitzar PreparedStatement.

Imagina que construeixes la consulta enganxant text que ve de l’usuari:

// MAI facis això!
String nom = /* text escrit per l'usuari */;
String sql = "SELECT * FROM usuari WHERE nom = '" + nom + "'";

Si l’usuari escriu x' OR '1'='1, la consulta es converteix en ... WHERE nom = 'x' OR '1'='1', que és sempre certa i retorna tots els usuaris.

Això és una injecció SQL, una de les vulnerabilitats més comunes.

ImportantRegla de seguretat

Fes servir sempre PreparedStatement per a qualsevol valor que vingui de l’usuari o d’una variable.

Amb ps.setString(1, nom), el text de l’usuari es tracta com a dada, mai com a codi SQL. És més segur i, a més, més net.

19.6 El patró DAO

Escampar codi SQL per tot el programa és una mala idea: si canvies la base de dades, has de tocar-ho tot.

El patró DAO (Data Access Object) separa la lògica de l’aplicació de l’accés a dades.

La idea: per a cada entitat (Llibre) creem un objecte DAO (LlibreDAO) que ofereix mètodes com inserir, cercaPerId, llistarTots, actualitzar, esborrar.

La resta del programa treballa amb objectes Llibre i no veu mai el SQL.

flowchart LR
    A["Aplicació<br/>(menú, lògica)"] -->|"objectes Llibre"| B["LlibreDAO"]
    B -->|"SQL via JDBC"| C[("biblioteca.db")]

La classe de domini

public class Llibre {
    private int id;
    private String titol;
    private String isbn;
    private int anyPublicacio;

    public Llibre(int id, String titol, String isbn, int anyPublicacio) {
        this.id = id;
        this.titol = titol;
        this.isbn = isbn;
        this.anyPublicacio = anyPublicacio;
    }
    // getters i setters omesos per brevetat
    public int getId() { return id; }
    public String getTitol() { return titol; }
    public String getIsbn() { return isbn; }
    public int getAnyPublicacio() { return anyPublicacio; }
}

La classe DAO del llibre

import java.sql.*;
import java.util.*;

public class LlibreDAO {

    private final String url = "jdbc:sqlite:biblioteca.db";

    private Connection connectar() throws SQLException {
        return DriverManager.getConnection(url);
    }

    public void inserir(Llibre llibre) {
        String sql = "INSERT INTO llibre (titol, isbn, any_publicacio) VALUES (?, ?, ?)";
        try (Connection conn = connectar();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, llibre.getTitol());
            ps.setString(2, llibre.getIsbn());
            ps.setInt(3, llibre.getAnyPublicacio());
            ps.executeUpdate();
        } catch (SQLException e) {
            System.out.println("Error en inserir: " + e.getMessage());
        }
    }

    public List<Llibre> llistarTots() {
        List<Llibre> llibres = new ArrayList<>();
        String sql = "SELECT id, titol, isbn, any_publicacio FROM llibre";
        try (Connection conn = connectar();
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {
            while (rs.next()) {
                llibres.add(new Llibre(
                    rs.getInt("id"),
                    rs.getString("titol"),
                    rs.getString("isbn"),
                    rs.getInt("any_publicacio")
                ));
            }
        } catch (SQLException e) {
            System.out.println("Error en llistar: " + e.getMessage());
        }
        return llibres;
    }

    public Llibre cercaPerId(int id) {
        String sql = "SELECT id, titol, isbn, any_publicacio FROM llibre WHERE id = ?";
        try (Connection conn = connectar();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setInt(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new Llibre(
                        rs.getInt("id"),
                        rs.getString("titol"),
                        rs.getString("isbn"),
                        rs.getInt("any_publicacio")
                    );
                }
            }
        } catch (SQLException e) {
            System.out.println("Error en cercar: " + e.getMessage());
        }
        return null;   // no trobat
    }

    public void esborrar(int id) {
        String sql = "DELETE FROM llibre WHERE id = ?";
        try (Connection conn = connectar();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setInt(1, id);
            ps.executeUpdate();
        } catch (SQLException e) {
            System.out.println("Error en esborrar: " + e.getMessage());
        }
    }
}

Ús del DAO des de l’aplicació

Fixa’t que el programa principal treballa amb objectes i no escriu ni una línia de SQL:

public class App {
    public static void main(String[] args) {
        LlibreDAO dao = new LlibreDAO();

        dao.inserir(new Llibre(0, "Solaris", "978-8433920423", 1961));

        System.out.println("--- Catàleg ---");
        for (Llibre l : dao.llistarTots()) {
            System.out.println(l.getId() + ": " + l.getTitol());
        }
    }
}
NotaEl DAO al projecte model

El projecte BiblioTech fa servir exactament aquest patró: una classe de domini per cada entitat i el seu DAO corresponent, amb SQLite com a magatzem.

Així la lògica del menú de consola queda neta i el dia de demà es podria canviar a MySQL tocant només els DAO.

19.7 Errors típics

  • No tancar recursos → usa try-with-resources sempre.
  • Llegir el ResultSet sense next() → ens donarà una SQLException.
  • Numerar paràmetres/columnes des del 0 → a JDBC comencen per 1.
  • Concatenar valors amb + a la SQL → risc d’injecció; usa PreparedStatement.
  • Oblidar el driver al pom.xml** → SQLException: No suitable driver found.
  • Confondre executeQuery (SELECT) amb executeUpdate (INSERT/UPDATE/DELETE).

19.8 Mini-exercicis

  1. Afegeix al LlibreDAO el mètode actualitzar(Llibre) que modifiqui títol, ISBN i any a partir de l’id.
  2. Escriu una consulta SELECT amb PreparedStatement que retorni els llibres publicats després d’un any donat com a paràmetre.
  3. Crea una taula soci (id, nom, carnet) i el seu SociDAO amb els mètodes CRUD complets.
  4. Explica per què PreparedStatement protegeix contra la injecció SQL, amb un exemple d’entrada maliciosa.

19.9 Resum

  • JDBC és l’API estàndard de Java per a bases de dades; el driver l’adapta a cada SGBD.
  • Obtens una Connection amb DriverManager i la tanques amb try-with-resources.
  • CRUD = INSERT/SELECT/UPDATE/DELETE; executeUpdate per modificar, executeQuery + ResultSet per consultar.
  • Usa PreparedStatement sempre que hi hagi valors variables (seguretat i rendiment).
  • El patró DAO separa la lògica de l’aplicació de l’accés a dades.
  • SQLite (un sol fitxer, sense servidor) és ideal per aprendre.