JDBC — Bonus : métadonnées de résultats et transactions

TP — ResultSetMetaData et pilotage du commit

java
jdbc
tp
bonus
Extraire le schéma d’un résultat à l’exécution, lire une table sans connaître ses colonnes, et garantir une opération multi-requêtes par une transaction.
Auteur
Affiliations

Emmanuel Bruno

Université de Toulon

LIS UMR CNRS 7020

Date de publication

2026-11-11

ImportantPrérequis

Ce TP est autonome : il installe lui-même les deux tables qu’il utilise (section 0). Il suppose un PostgreSQL accessible sur localhost:5432, base university, utilisateur et mot de passe postgres — la section 0 donne la commande pour en démarrer un jetable. Exécutez d’abord la cellule d’initialisation lst-jdbc-bonus-setup de la section 0 : elle installe la table students sans contrainte de clé primaire (4 lignes : Alice, Bob, Carol, Dave) et la table wallet.

Les conventions du cours sont rappelées dans 01_java_jdbc_L_IntroductionJDBC.qmd :

  • le rôle du paragraphe (contexte / question / réponse / check / bonus / sources) ;
  • le profil de sortie : text-output: raw pour une sortie console brute, blocs text sans exécution pour les sorties attendues.
//| label: lst-java-jdbc-bonus-init
//| classes: cell-source
//| output: false
import java.sql.*;

1 0. Installation des données

Contrairement aux leçons précédentes, celle-ci ne réutilise aucune base existante : elle a besoin d’un PostgreSQL frais et de deux tables précises — dont une sans clé primaire, parce que tout l’enjeu de la section 1 est là.

Démarrez un serveur PostgreSQL jetable — le port 5432 doit être libre, sinon adaptez la carte de ports et les URL de connexion du TP :

#| label: lst-jdbc-bonus-db
#| classes: cell-console
docker run --rm --name jdbc-bonus-pg \
  -e POSTGRES_DB=university -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
  -p 127.0.0.1:5432:5432 postgres:16

Puis initialisez les données. Comme tout ce TP, ce bloc est une capture d’atelier : il est exécuté dans votre environnement, pas par le rendu.

//| label: lst-jdbc-bonus-setup
//| classes: cell-source
//| output: false
import java.sql.*;

try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
     Statement statement = connection.createStatement()) {

    statement.executeUpdate("DROP TABLE IF EXISTS wallet");
    statement.executeUpdate("DROP TABLE IF EXISTS students");

    // Volontairement SANS clé primaire : c'est le point de départ du TP.
    statement.executeUpdate(
        "CREATE TABLE students (id serial, name varchar(80), email varchar(120))");
    statement.executeUpdate(
        "INSERT INTO students (name, email) VALUES "
            + "('Alice Martin', 'alice@example.com'),"
            + "('Bob Dupont', 'bob@example.com'),"
            + "('Carol Bernard', 'carol@example.com'),"
            + "('Dave Petit', 'dave@example.com')");

    statement.executeUpdate(
        "CREATE TABLE wallet (student_id integer, balance integer)");
    statement.executeUpdate(
        "INSERT INTO wallet (student_id, balance) VALUES (1, 500), (2, 500), (3, 500), (4, 500)");
}

2 Contexte

La leçon 02_java_jdbc_L_Avance.qmd a montré que les métadonnées de base de données — le schéma déclaré dans le serveur — se lisent via DatabaseMetaData. Ce TP explore deux mécanismes complémentaires, déclenchés par des situations que cette lecture du schéma laisse ouvertes :

  • Section 1 — La table students installée en section 0 n’a pas de clé primaire : pour cibler une ligne, il faudra s’appuyer sur une colonne qui en tient lieu, ici email. Peut-on lire le schéma d’un résultat — le nom et le type des colonnes réellement produites par une requête — sans rien connaître de la table ? C’est le rôle de ResultSetMetaData.
  • Section 2 — Quand une opération exécute plusieurs requêtes à la suite, que se passe-t-il si l’une échoue au milieu de l’autre ? C’est l’occasion de passer d’une cohérence assurée par le code Java à une garantie demandée à la base elle-même : la transaction SQL.

3 1. Métadonnées de résultats

Les métadonnées de la base décrivent ce qui est déclaré (tables, colonnes, contraintes). Celles d’un résultat décrivent ce qui est produit par la requête que l’on vient d’exécuter.

3.1 1.1 Le problème : un ResultSet sans notice d’emploi

Dans les TPs précédents, chaque lecture de ResultSet suppose que le codeur connaît déjà les colonnes de la requête : SELECT name, email s’accompagne d’un rs.getString("name") écrit à la main, colonne par colonne.

Comment écrire une méthode afficher(ResultSet rs) générique — qui affiche n’importe quel résultat, sans savoir à l’avance quelles colonnes la requête produit ?

Difficile de deviner : le ResultSet ne fournit qu’un accès par nom ou par index, encore faut-il connaître ce nom, ce nombre et ce type. La réponse existe pourtant, et c’est une brique utilisée en permanence — sans le savoir — par tous les outils qui affichent un résultat SQL de façon dynamique.

3.2 1.2 ResultSetMetaData : la description du résultat

ResultSetMetaData est l’équivalent d’une notice d’emploi attachée à chaque ResultSet : rs.getMetaData() renvoie, pour les colonnes de ce résultat, le nom, le nombre, le type SQL — exactement les informations qui manquent à la méthode générique.

Ces métadonnées décrivent le résultat, pas la table : deux requêtes différentes sur une même table produisent deux notices différentes (SELECT * et SELECT name, COUNT(*) GROUP BY name n’ont rien de comparable).

Inspectons le résultat de SELECT id, name, email FROM students :

//| label: lst-jdbc-bonus-resultsetmetadata
//| classes: cell-source
//| output: true
//| maxOutput: 25
import java.sql.Types;

String sql = "SELECT id, name, email FROM students";

try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
     Statement statement = connection.createStatement();
     ResultSet resultSet = statement.executeQuery(sql)) {

    ResultSetMetaData metaData = resultSet.getMetaData();
    int columnCount = metaData.getColumnCount();
    System.out.println("La requête \"" + sql + "\" contient " + columnCount + " colonnes.");

    for (int i = 1; i <= columnCount; i++) {
        String columnName = metaData.getColumnLabel(i);
        int columnType = metaData.getColumnType(i);
        String columnTypeName = metaData.getColumnTypeName(i);

        System.out.println("Colonne " + i + " : " + columnName + " (" + columnTypeName + ")");

        if (columnType == Types.INTEGER) {
            System.out.println("  Type : INTEGER");
        } else if (columnType == Types.VARCHAR) {
            System.out.println("  Type : VARCHAR");
        }
    }
}

3.3 1.3 Résultat observé

//| label: lst-jdbc-bonus-observed-result
//| classes: cell-console
//| echo: false
//| output: true
La requête "SELECT id, name, email FROM students" contient 3 colonnes.
Colonne 1 : id (int4)
  Type : INTEGER
Colonne 2 : name (varchar)
  Type : VARCHAR
Colonne 3 : email (varchar)
  Type : VARCHAR
NoteInterprétation

Sur ce résultat :

  • getColumnCount renvoie 3, le nombre de colonnes sélectionnées.
  • getColumnLabel renvoie le nom de la colonne (ou son alias AS s’il existe).
  • getColumnType renvoie une constante de java.sql.Types (INTEGER, VARCHAR, etc.).
  • getColumnTypeName renvoie le nom natif du type côté PostgreSQL (int4, varchar).

3.4 1.4 Affichage générique d’un ResultSet

Passons à la pratique avec une méthode printResultSet qui affiche n’importe quel ResultSet, quelle que soit sa structure.

//| label: lst-jdbc-bonus-generic-print
//| classes: cell-source
//| output: true
void printResultSet(ResultSet resultSet) throws SQLException {
    ResultSetMetaData metaData = resultSet.getMetaData();
    int columnCount = metaData.getColumnCount();

    // En-tête
    for (int i = 1; i <= columnCount; i++) {
        if (i > 1) {
            System.out.print(" | ");
        }
        System.out.print(metaData.getColumnLabel(i));
    }
    System.out.println();

    // Séparateur
    StringBuilder separator = new StringBuilder();
    for (int i = 1; i <= columnCount; i++) {
        if (i > 1) {
            separator.append("-+-");
        }
        separator.append("-".repeat(Math.max(10, metaData.getColumnLabel(i).length())));
    }
    System.out.println(separator);

    // Lignes
    int rowCount = 0;
    while (resultSet.next()) {
        for (int i = 1; i <= columnCount; i++) {
            if (i > 1) {
                System.out.print(" | ");
            }
            System.out.print(resultSet.getString(i));
        }
        System.out.println();
        rowCount++;
    }
    System.out.println("(" + rowCount + " lignes)");
}
NoteÀ quoi sert une méthode d’affichage générique ?

Dans un outil d’exploration de base — un client SQL, un visualiseur de données — on ne connaît pas la structure du résultat à l’avance : printResultSet découvre les colonnes à l’exécution et s’adapte, sans aucun getString("...") codé à la main.

Testez printResultSet sur le résultat de SELECT id, name, email FROM students. Votre sortie devrait afficher l’en-tête, le séparateur, les 4 lignes du jeu de données, puis le compteur :

//| label: lst-jdbc-bonus-test-printresultset
//| classes: cell-console
//| echo: false
//| output: true
id | name | email
-----------+-----------+----------
1 | Alice Martin | alice@example.com
2 | Bob Dupont | bob@example.com
3 | Carol Bernard | carol@example.com
4 | Dave Petit | dave@example.com
(4 lignes)
//| label: lst-jdbc-bonus-test-run
//| classes: cell-source
//| output: true
//| eval: false
try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
     Statement statement = connection.createStatement();
     ResultSet resultSet = statement.executeQuery("SELECT id, name, email FROM students")) {

    printResultSet(resultSet);
}

3.5 1.5 Découvrir les colonnes d’une table inconnue

Puisqu’un SELECT * produit un résultat dont les colonnes sont auto-décrites, on peut interroger une table sans en connaître le schéma — une requête qui ne rapporte aucune ligne suffit, ce qui la rend gratuite :

//| label: lst-jdbc-bonus-unknown-cols
//| classes: cell-source
//| output: true
//| maxOutput: 25
void afficherColonnesTable(Connection connection, String tableName) throws SQLException {
    // Une requête exécutée, même vide, porte ses métadonnées de colonnes.
    try (PreparedStatement ps = connection.prepareStatement("SELECT * FROM " + tableName + " LIMIT 0");
         ResultSet rs = ps.executeQuery()) {

        ResultSetMetaData meta = rs.getMetaData();
        System.out.println("Colonnes de la table " + tableName + " :");
        for (int i = 1; i <= meta.getColumnCount(); i++) {
            System.out.println("  - " + meta.getColumnLabel(i)
                    + " (" + meta.getColumnTypeName(i) + ")");
        }
    }
}

try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
    afficherColonnesTable(connection, "students");
}
//| label: lst-jdbc-bonus-unknown-cols-output
//| classes: cell-console
//| echo: false
//| output: true
Colonnes de la table students :
  - id (int4)
  - name (varchar)
  - email (varchar)
AstuceCas d’usage : la génération dynamique de code

Sur un projet réel, on écrit de nombreux objets d’accès aux données dont les méthodes se déduisent des colonnes de la table : les colonnes lues par ResultSetMetaData fournissent exactement l’information nécessaire pour générer ce code automatiquement.

3.6 1.6 Manipuler le résultat comme une table générique

Reprenons la requête de la section 1.2, cette fois sans rien connaître de ses colonnes : une fonction charger(ResultSet) construit une liste de lignes, chaque ligne étant un tableau de chaînes.

//| label: lst-jdbc-bonus-result-as-table
//| classes: cell-source
//| output: true
//| maxOutput: 25
import java.util.ArrayList;
import java.util.List;

List<String[]> charger(ResultSet resultSet) throws SQLException {
    ResultSetMetaData meta = resultSet.getMetaData();
    int colonnes = meta.getColumnCount();
    List<String[]> lignes = new ArrayList<>();

    while (resultSet.next()) {
        String[] ligne = new String[colonnes];
        for (int i = 0; i < colonnes; i++) {
            ligne[i] = resultSet.getString(i + 1);
        }
        lignes.add(ligne);
    }
    return lignes;
}

List<String[]> lignes;
try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
     Statement statement = connection.createStatement();
     ResultSet resultSet = statement.executeQuery("SELECT id, name, email FROM students")) {

    lignes = charger(resultSet);
}

System.out.println("Lignes récupérées : " + lignes.size());
System.out.println("Colonnes par ligne : " + lignes.getFirst().length);
//| label: lst-jdbc-bonus-result-as-table-output
//| classes: cell-console
//| echo: false
//| output: true
Lignes récupérées : 4
Colonnes par ligne : 3

La liste est transposable dans n’importe quel code générique — ici un affichage ligne par ligne, dans un autre contexte une exportation CSV :

//| label: lst-jdbc-bonus-print-rows
//| classes: cell-source
//| output: true
lignes.forEach(ligne -> System.out.println("  " + String.join(" | ", ligne)));
//| label: lst-jdbc-bonus-print-rows-output
//| classes: cell-console
//| echo: false
//| output: true
  1 | Alice Martin | alice@example.com
  2 | Bob Dupont | bob@example.com
  3 | Carol Bernard | carol@example.com
  4 | Dave Petit | dave@example.com
ImportantLa méthode générique ne connaît que la forme

À aucun moment le code n’a été modifié pour la requête : charger fonctionne pour n’importe quel résultat, mais elle ne produit que des chaînes, indexées par position. Impossible de savoir si la colonne 0 est un identifiant ou un nom — ResultSetMetaData décrit la forme d’une colonne (nom, type, taille), jamais sa signification métier. Le code métier — celui qui interprète les colonnes — reste à écrire, ou à générer.

3.7 1.7 Bonus : schéma d’un PreparedStatement

PreparedStatement expose ses métadonnées de résultat sans exécution : getMetaData() renvoie la description des colonnes que la requête produirait. Cet usage est hors programme : la méthode exige que le serveur puisse typer les colonnes sans exécuter la requête, ce qui n’est pas toujours possible. Retenez simplement que la description d’un résultat est accessible avant même de l’avoir produit.

4 2. Transactions SQL

Imaginons une opération enrollStudent qui insère une inscription puis met à jour le solde du portefeuille. Si la seconde requête échoue, que reste-t-il en base ?

La première requête a déjà été confirmée (commit) : l’inscription est en base alors que le solde n’a pas été débité. Le portefeuille et les inscriptions ne décrivent plus la même réalité.

Comment faire pour que tout soit annulé si une seule des requêtes échoue ?

4.1 2.1 setAutoCommit(false) : le début de la transaction

Par défaut, en JDBC, chaque instruction SQL est exécutée dans sa propre transaction (mode auto-commit) : elle est confirmée dès sa fin. Pour mettre plusieurs instructions dans une transaction unique, on désactive ce mode, on exécute les instructions, puis on confirme ou on annule selon le résultat.

//| label: lst-jdbc-bonus-tx-rollback
//| classes: cell-source
//| output: true
//| maxOutput: 25
try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
    connection.setAutoCommit(false); // Début de la transaction

    try (Statement statement = connection.createStatement()) {

        // Opération 1 : créer un étudiant
        statement.executeUpdate(
            "INSERT INTO students (name, email) " +
            "VALUES ('Frank Lee', 'frank@example.com')");
        System.out.println("Opération 1 : étudiant créé.");

        // Opération 2 : une erreur volontaire
        statement.executeUpdate("INSERT INTO table_inexistante VALUES (1)");

        connection.commit(); // jamais atteint
    } catch (SQLException e) {
        connection.rollback();
        System.out.println("Transaction annulée : " + e.getMessage());
    }
}

// Vérification : l'étudiant n'est pas apparu
try (Connection checkConnection = DriverManager.getConnection(
        "jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
     PreparedStatement check = checkConnection.prepareStatement(
             "SELECT COUNT(*) FROM students WHERE email = ?")) {

    check.setString(1, "frank@example.com");
    try (ResultSet rs = check.executeQuery()) {
        rs.next();
        System.out.println("Étudiants avec cet email : " + rs.getInt(1));
    }
}

Sortie console :

//| label: lst-jdbc-bonus-tx-output
//| classes: cell-console
//| echo: false
//| output: true
//| text-output: raw
Opération 1 : étudiant créé.
Transaction annulée : ERROR: relation "table_inexistante" does not exist
Étudiants avec cet email : 0

Ce qui s’est passé :

  1. l’erreur SQL a interrompu le flot normal de la séquence, et le commit() n’a jamais été atteint ;
  2. le catch a détecté l’erreur et déclenché l’annulation : l’insertion de Frank Lee est effacée, aucune trace ne reste de l’opération partielle ;
  3. sans le rollback(), l’insertion du point 1 serait définitive à la fermeture de la connexion.
NoteLes lignes System.out.println sont-elles annulées aussi ?

Non : le rollback annule les effets en base, pas les sorties déjà émises côté JVM. « Opération 1 : étudiant créé. » a été affiché puis annulé — c’est exactement ce qui rend les traces de débogage trompeuses en présence de transactions : elles racontent l’intention du code, pas l’état final de la base.

4.2 2.2 La transaction correcte

Voici le patron général d’une transaction JDBC.

//| label: lst-jdbc-bonus-tx-correct
//| classes: cell-source
//| output: true
//| maxOutput: 25
void transfererEtInscrire() throws SQLException {
    try (Connection connection = DriverManager.getConnection(
            "jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
        connection.setAutoCommit(false);

        try (Statement statement = connection.createStatement()) {

            // Opération 1
            statement.executeUpdate(
                "INSERT INTO students (name, email) " +
                "VALUES ('Erin Meyer', 'erin@example.com')");

            // Opération 2
            statement.executeUpdate(
                "UPDATE wallet SET balance = balance - 100 WHERE student_id = 1");

            connection.commit();
            System.out.println("Transaction confirmée.");
        } catch (SQLException e) {
            connection.rollback();
            System.out.println("Transaction annulée : " + e.getMessage());
            throw e; // Important : propager l'erreur pour que l'appelant le sache.
        }
    }
}

transfererEtInscrire();
//| label: lst-jdbc-bonus-tx-output2
//| classes: cell-console
//| echo: false
//| output: true
Transaction confirmée.
Importantthrow e : ne jamais avaler une exception dans une transaction

Le rollback annule les effets en base, mais côté Java le code appelant doit être prévenu de l’échec pour ne pas considérer l’opération comme réussie. Sans le throw e final, le programme continue avec des données qui n’existent plus.

5 3. Checkpoint

NoteCe que vous devez retenir

ResultSetMetaData :

  • rs.getMetaData() renvoie la description des colonnes du résultat courant ;
  • getColumnCount() : le nombre de colonnes ;
  • getColumnLabel(i) : le nom (ou l’alias) de la colonne i ;
  • getColumnType(i) : un code de java.sql.Types (INTEGER, VARCHAR, etc.) ;
  • getColumnTypeName(i) : le nom natif du type côté SGBD (int4, varchar).

Transactions SQL :

  • connection.setAutoCommit(false) place toutes les requêtes suivantes dans une seule transaction ;
  • connection.commit() valide tout ; connection.rollback() annule tout.

rollback() :

  • annule les deux opérations en cas d’échec de la seconde : sans lui, l’erreur SQL interrompt la méthode, le commit() n’est jamais atteint, et la première requête reste confirmée de façon irrévocable ;
  • avec lui, le rattrapage est explicite : après le rollback, la base est revenue à son état d’avant la transaction.

6 Récapitulatif JDBC

Les API vues dans ce notebook se recoupent ainsi : connexions et instructions dans 01_java_jdbc_L_IntroductionJDBC.qmd, pilotage avancé et métadonnées de base dans 02_java_jdbc_L_Avance.qmd, transactions appliquées dans 03_java_jdbc_P_Exemple.qmd (E3 « Tout ou rien ») et dans ce TP.

Réutilisation