Skip to content

10. L'accès aux bases de données avec JDBC

10.1. Le pilote JDBC

Reprenons l'architecture en couches utilisée à diverses reprises depuis le chapitre 6. Jusqu'à présent, la couche [dao] de l'application [Impots] a exploité trois types de sources de données :

  • des données placées « en dur » dans le code ([HardwiredImpot]) ;
  • des données provenant d'un fichier texte ([FileImpot]) ;
  • des données provenant d'un fichier JSON ([JsonImpot]).

Nous étudions dans ce chapitre le cas où les données proviennent d'une base de données. L'architecture devient la suivante :

Image

Dans le schéma ci-dessus, la couche [dao] [1] dialogue avec le SGBD (Système de Gestion de Bases de Données) [3], qui gère la base de données [4], au travers d'une bibliothèque de classes propre au SGBD utilisé [2]. Cette bibliothèque implémente une API standard de la plateforme Java, JDBC (« Java DataBase Connectivity »), présente dans le JDK depuis sa version 1.1, en 1997. On l'appelle un pilote JDBC (« JDBC driver »). Tous les SGBD courants disposent d'un pilote JDBC, distribué sous la forme d'une archive [.jar].

Kotlin n'a pas sa propre API d'accès aux bases de données : un programme Kotlin exécuté sur la JVM utilise JDBC directement, comme il utilise les fichiers NIO ou les threads du JDK. C'est l'un des grands atouts du langage : tous les pilotes JDBC écrits pour Java, et toutes les bibliothèques construites au-dessus d'eux, sont utilisables sans adaptation. Ce que Kotlin apporte, nous le verrons tout au long du chapitre, c'est une écriture plus concise et plus sûre du code JDBC : fonction [use] pour fermer les ressources, fonctions d'extension pour lire les résultats, classes de données pour représenter les lignes, absence d'exceptions contrôlées.

JDBC est d'abord un ensemble d'interfaces, définies dans les paquetages [java.sql] et [javax.sql] du module [java.sql] du JDK : [Connection], [Statement], [PreparedStatement], [ResultSet], [DataSource]… Un pilote JDBC fournit des classes qui implémentent ces interfaces. Le code de l'application, lui, ne manipule que les interfaces : il ne contient pas, en général, le nom des classes du pilote. C'est une différence avec d'autres plateformes, où le code utilise souvent directement les classes propres à chaque SGBD ([MySqlConnection], [SqlConnection]… en C#) : sur la JVM, l'abstraction est la règle depuis l'origine, et changer de SGBD revient d'abord à changer de pilote et d'adresse de la base, comme nous le verrons. Il reste que chaque SGBD a son dialecte SQL : cette indépendance a des limites, que nous verrons aussi.

Nous utilisons un seul SGBD, MySQL 8, que l'annexe « Le SGBD MySQL avec Laragon » installe sous Windows, et son pilote JDBC officiel, MySQL Connector/J, dans sa version 9.7.0. Les bases sont créées par des scripts SQL exécutés avec le client en ligne de commande [mysql] ; l'annexe montre aussi comment les créer avec le client graphique HeidiSQL livré avec Laragon.

10.2. Les deux modes d'exploitation d'une source de données

Une base de données peut être exploitée de deux manières différentes :

  • le mode connecté ;
  • le mode déconnecté.

En mode connecté, l'application :

  • ouvre une connexion avec la source de données ;
  • travaille avec la source de données en lecture / écriture ;
  • ferme la connexion.

En mode déconnecté, l'application :

  • ouvre une connexion avec la source de données ;
  • obtient une copie en mémoire de tout ou partie des données de la source ;
  • ferme la connexion ;
  • travaille avec la copie en mémoire des données, en lecture / écriture ;
  • lorsque le travail est fini, ouvre une connexion, envoie les données modifiées à la source de données pour qu'elle les prenne en compte, et ferme la connexion.

Le mode connecté est celui de l'interface [ResultSet] de JDBC : les lignes du résultat d'une requête sont lues pendant que la connexion est ouverte. Le mode déconnecté est celui de l'interface [CachedRowSet] du paquetage [javax.sql.rowset] (Java 5) : une table en mémoire qui se remplit à partir d'une requête, se modifie hors connexion, puis renvoie ses modifications à la base. Ces « RowSet » sont toujours présents dans le JDK 25 mais ne sont plus guère utilisés dans les applications nouvelles : le travail sur une copie en mémoire des données, avec enregistrement ultérieur des modifications, est aujourd'hui confié à un ORM (« Object-Relational Mapper »), qui manipule des objets ordinaires plutôt que des lignes de tables. L'ORM le plus utilisé sur la JVM, Hibernate, est l'objet du chapitre 11 ; il s'utilise en Kotlin comme en Java. Nous étudions donc surtout le mode connecté, sur lequel repose d'ailleurs Hibernate, et nous ne montrerons le mode déconnecté que par un exemple, en fin de chapitre.

10.3. Les concepts de base de l'exploitation d'une base de données

10.3.1. La base de données exemple

Nous allons exposer les principaux concepts de l'utilisation d'une base de données avec une base MySQL nommée [dbarticles]. Elle contient une seule table, [articles], dont chaque ligne décrit un article d'un magasin :

  • [ID] : un identifiant unique de l'article, clé primaire de la table ;
  • [NOM] : le nom de l'article, unique lui aussi ;
  • [PRIX] : son prix ;
  • [STOCKACTUEL] : son stock actuel ;
  • [STOCKMINIMUM] : le stock minimum en deçà duquel il faut réapprovisionner l'article.

La base est créée par le script SQL [sql/dbarticles.sql] suivant :

-- création de la base [dbarticles] et de sa table [articles]
-- exécution : mysql -uroot < dbarticles.sql

-- le script est écrit en UTF-8
SET NAMES utf8mb4;

-- la base est recréée à chaque exécution du script
DROP DATABASE IF EXISTS dbarticles;
CREATE DATABASE dbarticles CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

-- choix de la base de données courante
USE dbarticles;

-- création de la table des articles
CREATE TABLE articles (
    ID            INT PRIMARY KEY AUTO_INCREMENT,
    NOM           VARCHAR(30) NOT NULL,
    PRIX          DECIMAL(10,2) NOT NULL,
    STOCKACTUEL   INT NOT NULL,
    STOCKMINIMUM  INT NOT NULL,
    -- contraintes d'intégrité
    CONSTRAINT UNQ_NOM UNIQUE (NOM),
    CONSTRAINT CHK_NOM CHECK (NOM <> ''),
    CONSTRAINT CHK_PRIX CHECK (PRIX > 0),
    CONSTRAINT CHK_STOCKACTUEL CHECK (STOCKACTUEL >= 0),
    CONSTRAINT CHK_STOCKMINIMUM CHECK (STOCKMINIMUM >= 0)
);

-- insertion de données dans la table
INSERT INTO articles (NOM, PRIX, STOCKACTUEL, STOCKMINIMUM) VALUES
    ('vélo', 500, 10, 5),
    ('pompe', 10, 10, 2),
    ('arc', 600, 4, 1),
    ('flèches - lot de 6', 100, 12, 20),
    ('combinaison de plongée', 300, 8, 2),
    ('bouteilles d''oxygène', 120, 10, 5);
  • ligne 5 : le script est un fichier texte codé en UTF-8. [SET NAMES utf8mb4] l'indique au serveur, sans quoi les caractères accentués des lignes 31-36 pourraient être mal interprétés par le client [mysql] ;
  • lignes 8-9 : la base est supprimée si elle existe, puis recréée. Le script peut ainsi être exécuté plusieurs fois pour remettre la base dans son état initial. [utf8mb4] est le codage Unicode complet de MySQL (le codage historique [utf8] de MySQL ne code que les caractères sur trois octets au plus) ;
  • ligne 12 : [dbarticles] devient la base courante : les ordres SQL qui suivent s'exécuteront sur elle ;
  • lignes 15-27 : la définition de la table [articles] :
    • ligne 16 : la colonne [ID] est la clé primaire de la table ([PRIMARY KEY]). L'attribut [AUTO_INCREMENT], propre à MySQL, indique que sa valeur est générée par le SGBD lui-même lors de l'insertion d'une ligne : 1, 2, 3… Les autres SGBD ont des notions équivalentes, avec une autre syntaxe ([IDENTITY] pour SQL Server, [GENERATED … AS IDENTITY] pour PostgreSQL et Oracle) ;
    • ligne 18 : le prix est un nombre décimal exact de 10 chiffres dont 2 après la virgule. On évite pour des montants les types à virgule flottante comme [DOUBLE] (cf. la différence entre [Double] et [BigDecimal] au chapitre 3). Le type SQL [DECIMAL] correspond à la classe [java.math.BigDecimal], que Kotlin utilise telle quelle ;
    • lignes 22-26 : des contraintes d'intégrité. [UNQ_NOM] interdit que deux articles aient le même nom ; les contraintes [CHECK] interdisent un nom vide, un prix négatif ou nul, un stock négatif. MySQL ignorait silencieusement les contraintes [CHECK] jusqu'à sa version 8.0.16 ; il les applique depuis ;
  • lignes 30-36 : l'insertion de six lignes. La colonne [ID] n'est pas initialisée : c'est le SGBD qui lui donne une valeur.

On exécute le script avec le client en ligne de commande [mysql], dans le dossier [sql] (l'utilisateur [root] de MySQL n'a pas de mot de passe dans l'installation de l'annexe ; sinon on ajoute l'option [-p] et le client le demande) :

> mysql -uroot < dbarticles.sql

La commande n'affiche rien si tout se passe bien. On peut vérifier la structure et le contenu de la table avec le même client :

> mysql -uroot --default-character-set=utf8mb4 -t dbarticles -e "describe articles; select * from articles"
+--------------+---------------+------+-----+---------+----------------+
| Field        | Type          | Null | Key | Default | Extra          |
+--------------+---------------+------+-----+---------+----------------+
| ID           | int           | NO   | PRI | NULL    | auto_increment |
| NOM          | varchar(30)   | NO   | UNI | NULL    |                |
| PRIX         | decimal(10,2) | NO   |     | NULL    |                |
| STOCKACTUEL  | int           | NO   |     | NULL    |                |
| STOCKMINIMUM | int           | NO   |     | NULL    |                |
+--------------+---------------+------+-----+---------+----------------+
+----+-------------------------+--------+-------------+--------------+
| ID | NOM                     | PRIX   | STOCKACTUEL | STOCKMINIMUM |
+----+-------------------------+--------+-------------+--------------+
|  1 | vélo                    | 500.00 |          10 |            5 |
|  2 | pompe                   |  10.00 |          10 |            2 |
|  3 | arc                     | 600.00 |           4 |            1 |
|  4 | flèches - lot de 6      | 100.00 |          12 |           20 |
|  5 | combinaison de plongée  | 300.00 |           8 |            2 |
|  6 | bouteilles d'oxygène    | 120.00 |          10 |            5 |
+----+-------------------------+--------+-------------+--------------+
  • ligne 1 : l'option [-t] demande un affichage en tableau, l'option [-e] donne les ordres SQL à exécuter, et l'option [--default-character-set=utf8mb4] fait afficher correctement les caractères accentués dans un terminal UTF-8 ;
  • lignes 3-9 : la structure de la table, obtenue par l'ordre MySQL [describe] ;
  • lignes 12-19 : le contenu de la table. Les valeurs de la colonne [ID] ont été générées par le SGBD.

Une remarque sur la casse des noms : les noms de colonnes ne distinguent jamais majuscules et minuscules ([nom] et [NOM] désignent la même colonne), mais les noms de tables sont des noms de fichiers du serveur. Ils ne distinguent pas majuscules et minuscules sous Windows et les distinguent sous Linux. Pour que les exemples fonctionnent partout, la table s'appelle [articles], en minuscules, et les requêtes l'écrivent ainsi.

Les programmes de ce chapitre modifient la table [articles]. Les résultats reproduits ont été obtenus en exécutant de nouveau le script [dbarticles.sql] avant chaque programme : le lecteur qui veut retrouver exactement les mêmes résultats fera de même.

Maintenant que nous avons une base de données, nous allons pouvoir l'exploiter. Auparavant, faisons quelques rappels de SQL.

10.3.2. Les quatre commandes de base du langage SQL

SQL (« Structured Query Language ») est un langage, partiellement normalisé, d'interrogation et de mise à jour des bases de données. Tous les SGBD respectent la partie normalisée de SQL mais ajoutent au langage des extensions propriétaires qui exploitent leurs particularités. Nous en avons déjà rencontré deux exemples : la génération automatique des clés primaires et les types autorisés pour les colonnes d'une table dépendent souvent du SGBD.

Les quatre commandes de base du langage SQL que nous présentons sont normalisées et acceptées par tous les SGBD :

Commande
Rôle
[select col1, col2, … from table1, table2, … where condition order by expression]
La requête qui permet d'obtenir les données contenues dans une base. Seuls les mots clés [select] et [from] sont obligatoires. Une jointure est faite avec toutes les tables qui suivent le mot clé [from] ; seules les colonnes qui suivent le mot clé [select] sont conservées ; seules les lignes vérifiant la condition du mot clé [where] sont conservées ; les lignes résultantes, ordonnées selon l'expression du mot clé [order by], forment le résultat de la requête. Ce résultat est une table.
[insert into table(col1, col2, …) values (val1, val2, …)]
Insère une ligne dans [table]. [(col1, col2, …)] précise les colonnes de la ligne à initialiser avec les valeurs [(val1, val2, …)].
[update table set col1=val1, col2=val2, … where condition]
Met à jour les lignes de [table] vérifiant [condition] (toutes les lignes s'il n'y a pas de [where]). Pour ces lignes, la colonne [coli] reçoit la valeur [vali].
[delete from table where condition]
Supprime toutes les lignes de [table] vérifiant [condition] (toutes les lignes s'il n'y a pas de [where]).

Nous allons écrire une application console permettant d'émettre des ordres SQL sur la base [dbarticles]. Voici un exemple d'exécution (les ordres SQL tapés au clavier figurent après l'invite « Requête SQL » ; pour ce document, ils ont été fournis au programme par une redirection de l'entrée standard, puis replacés après l'invite à laquelle ils répondent) ; le lecteur est invité à comprendre les ordres SQL émis et leurs résultats :

> gradlew -q :jdbc:run
URL JDBC de la base : [jdbc:mysql://localhost:3306/dbarticles], utilisateur : [root]

Requête SQL (rien pour arrêter) : select id,nom,prix,stockactuel,stockminimum from articles

------------------------------------
id,nom,prix,stockactuel,stockminimum
------------------------------------

1 vélo 500.00 10 5
2 pompe 10.00 10 2
3 arc 600.00 4 1
4 flèches - lot de 6 100.00 12 20
5 combinaison de plongée 300.00 8 2
6 bouteilles d'oxygène 120.00 10 5

Requête SQL (rien pour arrêter) : insert into articles(nom,prix,stockactuel,stockminimum) values('x',100,10,1)
Il y a eu 1 ligne(s) modifiée(s)

Requête SQL (rien pour arrêter) : select * from articles

------------------------------------
ID,NOM,PRIX,STOCKACTUEL,STOCKMINIMUM
------------------------------------

1 vélo 500.00 10 5
2 pompe 10.00 10 2
3 arc 600.00 4 1
4 flèches - lot de 6 100.00 12 20
5 combinaison de plongée 300.00 8 2
6 bouteilles d'oxygène 120.00 10 5
7 x 100.00 10 1

Requête SQL (rien pour arrêter) : update articles set prix=prix*1.1 where nom='x'
Il y a eu 1 ligne(s) modifiée(s)

Requête SQL (rien pour arrêter) : select id,nom,prix from articles where nom='x'

-----------
id,nom,prix
-----------

7 x 110.00

Requête SQL (rien pour arrêter) : delete from articles where nom='x'
Il y a eu 1 ligne(s) modifiée(s)

Requête SQL (rien pour arrêter) : select count(*) from articles

--------
count(*)
--------

6

Requête SQL (rien pour arrêter) : selectt * from articles
Requête invalide. Utilisez select, insert, update, delete ou rien pour arrêter

Requête SQL (rien pour arrêter) : select * from article
Erreur d'accès à la base de données (Table 'dbarticles.article' doesn't exist)

Requête SQL (rien pour arrêter) : insert into articles(nom,prix,stockactuel,stockminimum) values('vélo',100,10,1)
Erreur d'accès à la base de données (Duplicate entry 'vélo' for key 'articles.UNQ_NOM')

Requête SQL (rien pour arrêter) :
  • ligne 2 : l'URL JDBC de la base et l'utilisateur MySQL : ce sont les paramètres qui permettent de se connecter à la base de données ;
  • ligne 4 : on demande le contenu de la table [articles] ;
  • ligne 17 : on insère une nouvelle ligne. On notera que la colonne [id] n'est pas initialisée dans cette opération car c'est le SGBD qui va générer sa valeur ;
  • ligne 20 : vérification. Ligne 32, la ligne a bien été ajoutée, avec l'[id] 7. Avec [select *], les noms des colonnes sont ceux de la table, en majuscules ;
  • ligne 34 : on augmente de 10 % le prix de l'article qui vient d'être ajouté ;
  • ligne 43 : l'augmentation du prix a bien eu lieu ;
  • ligne 45 : on supprime l'article ajouté précédemment ;
  • ligne 48 : on vérifie qu'il ne reste plus que six articles ;
  • ligne 56 : le programme n'accepte que les quatre ordres [select], [insert], [update] et [delete] ;
  • ligne 60 : une erreur signalée par le SGBD : la table [article] n'existe pas ;
  • ligne 63 : la contrainte d'unicité [UNQ_NOM] interdit d'ajouter un second article [vélo]. Les messages d'erreur de MySQL, comme ceux du pilote, sont en anglais.

10.3.3. Les interfaces de base de JDBC pour le mode connecté

Revenons au schéma d'une application exploitant une base de données au travers d'un pilote JDBC :

Image

En mode connecté, l'application :

  • ouvre une connexion avec la source de données ;
  • travaille avec la source de données en lecture / écriture ;
  • ferme la connexion.

Trois types d'objets sont principalement concernés par ces opérations :

  • la connexion ([Connection]), qui encapsule les propriétés et méthodes de la liaison avec la base ;
  • l'ordre SQL ([Statement], ou [PreparedStatement] pour un ordre paramétré), qui encapsule les propriétés et méthodes de l'ordre SQL exécuté ;
  • le résultat ([ResultSet]), qui encapsule les propriétés et méthodes du résultat d'un ordre SQL [select].

Ce sont des interfaces du paquetage [java.sql]. Le pilote MySQL les implémente par des classes de ses propres paquetages ([com.mysql.cj.jdbc.ConnectionImpl], [ClientPreparedStatement], [ResultSetImpl]…), que l'application n'a pas à connaître : elle obtient ses objets par des méthodes qui rendent des interfaces. La connexion est obtenue de la classe [DriverManager] (ou d'une source de données [DataSource], que nous verrons plus loin), l'ordre SQL est créé par la connexion, le résultat est rendu par l'ordre SQL.

Ces interfaces sont des interfaces Java. Kotlin les utilise avec ses règles habituelles d'accès au code Java : un couple d'accesseurs [getXxx] / [setXxx] s'utilise comme une propriété [xxx] ([resultat.metaData] plutôt que [resultat.getMetaData()], [connexion.autoCommit = false] plutôt que [connexion.setAutoCommit(false)]), et les valeurs rendues par une méthode Java ont un type de plate-forme (noté [String!] dans les messages du compilateur) : Kotlin ne sait pas si elles peuvent être [null] et laisse le développeur choisir de les traiter comme [String] ou comme [String?] (chapitre 3). Nous verrons que ce point compte pour les colonnes qui peuvent contenir la valeur SQL [NULL].

10.3.3.1. L'interface Connection

Elle sert à gérer la connexion avec la base de données. Une connexion est obtenue par la méthode statique [DriverManager.getConnection(url, utilisateur, motDePasse)], qui reçoit l'URL JDBC de la base, par exemple [jdbc:mysql://localhost:3306/dbarticles], et les identifiants de l'utilisateur. La connexion rendue est déjà ouverte. Les méthodes de l'interface [Connection] que nous utiliserons sont les suivantes :

Méthode
Rôle
[createStatement]
crée un ordre SQL [Statement] associé à cette connexion.
[prepareStatement]
[prepareStatement(sql)] crée un ordre SQL paramétré [PreparedStatement] associé à cette connexion.
[prepareCall]
[prepareCall("{call nom(?, ?)}")] crée un ordre [CallableStatement] qui exécute une procédure stockée dans la base.
[autoCommit]
[autoCommit = false] (la méthode Java [setAutoCommit]) démarre une transaction (voir plus loin).
[commit], [rollback]
valide, annule la transaction en cours.
[metaData]
(la méthode Java [getMetaData]) rend un objet [DatabaseMetaData] qui décrit le SGBD et le pilote.
[isValid]
[isValid(secondes)] vérifie que la connexion est toujours utilisable.
[close]
ferme la connexion, ainsi que les ordres SQL et les résultats qu'elle a créés.

L'ouverture d'une connexion a la forme suivante (les schémas de code de ce paragraphe sont extraits du fichier [modeles/Modeles.kt] du projet, qui compile mais n'est pas exécuté) :

1
2
3
    val connexion = DriverManager.getConnection(URL, "root", "")
    // ... travail avec la connexion
    connexion.close()

L'inférence de type donne à [connexion] le type [Connection!] : une connexion, qu'on peut utiliser sans test de nullité ([getConnection] ne rend jamais [null] : en cas d'échec, elle lance une exception).

10.3.3.2. L'interface Statement

Elle sert à exécuter un ordre SQL. Les méthodes de cette interface que nous utiliserons sont les suivantes :

Méthode
Rôle
[executeQuery]
[executeQuery(sql)] exécute un ordre SQL [select]. On obtient un objet [ResultSet] représentant le résultat du [select].
[executeUpdate]
[executeUpdate(sql)] exécute un ordre SQL [update], [insert], [delete] (ou un ordre de définition comme [create table]). On obtient le nombre de lignes affectées par l'opération (mises à jour, insérées, supprimées). [executeLargeUpdate] (Java 8) rend ce nombre sous la forme d'un [Long].
[execute]
[execute(sql)] exécute un ordre SQL quelconque. Rend [true] si l'ordre a produit un [ResultSet], obtenu ensuite par la propriété [resultSet], [false] s'il a produit un nombre de lignes, obtenu par la propriété [updateCount].
[addBatch], [executeBatch]
ajoutent un ordre à un lot, exécutent le lot (voir plus loin).
[generatedKeys]
(la méthode Java [getGeneratedKeys]) rend les clés primaires générées par le SGBD lors d'un [insert].
[queryTimeout]
[queryTimeout = secondes] fixe la durée maximale d'exécution de l'ordre.
[close]
ferme l'ordre SQL, ainsi que son [ResultSet].

L'interface [PreparedStatement] dérive de [Statement] : c'est un ordre SQL paramétré, dont le texte est fixé à sa création et dont les paramètres reçoivent leur valeur par des méthodes [setXxx]. Nous l'étudierons au paragraphe « Les requêtes paramétrées ». L'exécution d'ordres SQL sans transaction a la forme suivante :

    // ouverture de la connexion
    val connexion = DriverManager.getConnection(URL, "root", "")
    // création d'un ordre SQL
    val ordre = connexion.createStatement()
    // exécution d'un ordre select
    val resultat = ordre.executeQuery("select ...")
    // ...
    // exécution d'un ordre update, insert, delete
    val nbLignesInserees = ordre.executeUpdate("insert ...")
    // ...
    // fermeture de la connexion
    connexion.close()

10.3.3.3. L'interface ResultSet

Elle encapsule les résultats d'un ordre SQL [select]. Un objet [ResultSet] représente une table avec des lignes et des colonnes, que l'on exploite séquentiellement : d'abord la première ligne, puis la deuxième, etc., sans retour en arrière possible (c'est le type par défaut [TYPE_FORWARD_ONLY] ; JDBC connaît aussi des résultats que l'on peut parcourir dans les deux sens, mais on les utilise rarement). Le [ResultSet] a un curseur placé initialement avant la première ligne : il faut appeler [next] pour lire la première ligne. Les méthodes de cette interface que nous utiliserons sont les suivantes :

Méthode
Rôle
[next]
passe à la ligne suivante de la table résultat. Rend [true] si la lecture a pu se faire, [false] s'il n'y a plus de ligne.
[getInt]
[getInt(i)] rend la valeur entière de la colonne n° i de la ligne courante. Les colonnes sont numérotées à partir de 1, et non de 0. [getInt("nom")] rend la valeur de la colonne de nom [nom]. Les autres méthodes analogues sont [getBoolean], [getBigDecimal], [getDouble], [getLong], [getString]… Comme elles ont un paramètre, Kotlin ne les transforme pas en propriétés.
[getObject]
[getObject(i)] rend la valeur de la colonne n° i sous la forme d'un [Any] (un [Object] Java), du type choisi par le pilote ; [getObject(i, LocalDate::class.java)] la rend dans le type demandé (en Kotlin, [X::class.java] désigne l'objet [Class] Java d'une classe, le [X.class] de Java).
[wasNull]
rend [true] si la dernière colonne lue n'avait pas de valeur, ce qui est symbolisé par la valeur SQL [NULL]. Les méthodes qui rendent un objet ([getString], [getBigDecimal], [getObject]…) rendent alors [null] ; celles qui rendent un type de base ([getInt], [getDouble]…) rendent 0 : seul [wasNull] permet de distinguer le 0 du [NULL].
[metaData]
(la méthode Java [getMetaData]) rend un objet [ResultSetMetaData] qui décrit les colonnes du résultat : [columnCount] (le nombre de colonnes), [getColumnLabel(i)] (le nom de la colonne n° i), [getColumnTypeName(i)] (son type SQL)…
[close]
ferme le résultat.

L'exploitation d'un objet [ResultSet] ressemble souvent à ce qui suit :

    // ouverture de la connexion
    val connexion = DriverManager.getConnection(URL, "root", "")
    // exécution de l'ordre select
    val ordre = connexion.createStatement()
    val resultat = ordre.executeQuery("select ...")
    // exploitation des résultats
    while (resultat.next()) {
        // exploiter la ligne courante
        // ...
    }
    // fermeture du résultat, de l'ordre et de la connexion
    resultat.close()
    ordre.close()
    connexion.close()

La boucle [while (resultat.next())] est la même qu'en Java : nous verrons qu'en Kotlin on l'écrit une fois pour toutes dans une fonction d'extension, plutôt que de la répéter dans chaque fonction qui lit un résultat.

Le type de plate-forme mérite ici une remarque. Pour une colonne qui accepte la valeur SQL [NULL], [getString(i)] rend [null] ; si l'on écrit [val nom: String = resultat.getString(2)], Kotlin insère une vérification et lance une [NullPointerException] dès l'affectation, avec le message « getString(...) must not be null ». Il faut donc déclarer [String?] les valeurs des colonnes qui peuvent être [NULL], et les traiter ensuite avec les opérateurs [?.] et [?:] du chapitre 3. Dans notre table, toutes les colonnes sont [NOT NULL] : le problème ne se pose pas.

Avec MySQL Connector/J, les lignes du résultat sont par défaut toutes transférées du serveur vers la mémoire de l'application dès l'exécution de la requête ; [next] les parcourt ensuite. Pour un résultat de plusieurs millions de lignes, on peut demander au pilote de les recevoir au fur et à mesure de leur lecture (paramètre [useCursorFetch=true] de l'URL et [fetchSize = n] sur l'ordre SQL) : c'est un réglage propre au pilote, que nous n'utiliserons pas.

10.3.4. La gestion des erreurs

La couche [dao] peut rencontrer de nombreuses erreurs lors de l'exploitation de la base de données : serveur arrêté, base inexistante, mot de passe incorrect, erreur de syntaxe SQL, violation d'une contrainte d'intégrité… Celles-ci sont remontées sous la forme d'exceptions [SQLException]. En Java, [SQLException] est une exception contrôlée : toutes les méthodes de JDBC la déclarent dans leur clause [throws], et le compilateur Java oblige le code qui les appelle à l'intercepter ou à la déclarer à son tour. Kotlin n'a pas d'exceptions contrôlées (chapitre 3) : le compilateur n'impose rien, et une fonction Kotlin qui appelle JDBC n'a rien à déclarer. C'est une liberté, et une responsabilité : rien ne signale au développeur qu'une ligne peut échouer, c'est à lui de décider où les erreurs sont traitées. Une couche [dao] doit gérer ces erreurs et, quoi qu'il arrive, libérer les ressources qu'elle a obtenues : la connexion, l'ordre SQL et le résultat doivent être fermés. Écrit avec des clauses [finally], comme on le faisait en Java avant Java 7, le code vu plus haut pour exploiter le résultat d'un ordre [select] devient le suivant :

    var connexion: Connection? = null
    try {
        // ouverture de la connexion
        connexion = DriverManager.getConnection(URL, "root", "")
        // exécution de l'ordre select
        val ordre = connexion.createStatement()
        val resultat = ordre.executeQuery("select ...")
        try {
            // exploitation des résultats
            while (resultat.next()) {
                // ...
            }
        } finally {
            // fermeture du résultat
            resultat.close()
        }
    } catch (e: SQLException) {
        // gestion de l'exception
        // ...
    } finally {
        // fermeture de la connexion (qui ferme aussi ses ordres)
        try {
            connexion?.close()
        } catch (e: SQLException) {
            // on ne peut plus rien faire
        }
    }

Ce code est lourd : la connexion doit être déclarée [var], de type [Connection?], avant le [try] pour être visible dans la clause [finally], et sa fermeture peut elle-même lancer une [SQLException] (ligne 23), qu'il faut intercepter dans la clause [finally]. On notera l'appel sûr [connexion?.close()] : si la connexion n'a pas pu être ouverte, [connexion] vaut [null] et rien n'est fait.

Le chapitre 3 a présenté la solution de Kotlin : la fonction d'extension use, définie pour tout objet [AutoCloseable], exécute le bloc (la lambda) qu'on lui passe et ferme l'objet à la sortie du bloc, quelle que soit la façon dont on en sort. C'est l'équivalent de l'instruction try-with-resources de Java. Les objets [Connection], [Statement] et [ResultSet] implémentent l'interface [AutoCloseable] :

    try {
        // chaque ressource est fermée à la sortie de son bloc [use], quelle que soit la façon dont on en sort
        DriverManager.getConnection(URL, "root", "").use { connexion ->
            connexion.createStatement().use { ordre ->
                ordre.executeQuery("select ...").use { resultat ->
                    // exploitation des résultats
                    while (resultat.next()) {
                        // ...
                    }
                }
            }
        }
    } catch (e: SQLException) {
        // gestion de l'exception
        // ...
    }
  • lignes 3-5 : trois ressources sont ouvertes, chacune par un [use]. L'objet ouvert est le paramètre de la lambda ([connexion], [ordre], [resultat]) ; on lui donne un nom plutôt que d'utiliser [it], car les trois lambdas sont imbriquées et un [it] désignerait l'objet du [use] le plus intérieur. Contrairement au try-with-resources de Java, qui accepte plusieurs ressources entre ses parenthèses, [use] ne gère qu'une ressource : on imbrique donc les [use]. Les ressources sont fermées dans l'ordre inverse de leur ouverture : le résultat, puis l'ordre SQL, puis la connexion. Une exception lancée par une fermeture n'efface pas celle qui a éventuellement provoqué la sortie du bloc : elle lui est attachée comme exception « supprimée » ([suppressedExceptions]), comme en Java ;
  • ligne 13 : la clause [catch] intercepte aussi bien les erreurs de connexion et d'exécution que celles des fermetures.

[use] étant une fonction [inline] (chapitre 7), son bloc n'est pas un objet lambda : il est recopié à la place de l'appel, et une instruction [return] écrite dans le bloc termine la fonction englobante, après la fermeture de la ressource. De plus, [use] rend la valeur de son bloc : [val n = connexion.createStatement().use { it.executeUpdate(sql) }] exécute l'ordre, ferme l'objet [Statement] et rend le nombre de lignes modifiées. Nous utiliserons souvent ces deux propriétés.

L'intérêt de [use] n'est pas seulement l'économie de lignes : il évite au développeur de fermer lui-même la connexion. Or l'oubli de la fermeture d'une connexion peut passer inaperçu et « planter » l'application d'une façon qui paraîtra aléatoire, chaque fois que le SGBD (ou le pool de connexions, que nous verrons) atteindra le nombre maximal de connexions ouvertes qu'il peut supporter. Dans la suite du cours, toute ressource JDBC est ouverte par un use.

La classe [SQLException] donne trois informations sur l'erreur, que Kotlin présente comme des propriétés :

  • [message] : le message d'erreur ;
  • [errorCode] : le code d'erreur propre au SGBD (le numéro d'erreur MySQL : 1049, 1062…) ;
  • [sqlState] : un code d'erreur de cinq caractères défini par la norme SQL (« SQLSTATE »), commun à tous les SGBD pour les erreurs les plus courantes : la classe [42] désigne une erreur de syntaxe ou d'accès, [23] une violation de contrainte d'intégrité, [08] une erreur de connexion…

On notera la propriété [sqlState], issue de la méthode Java [getSQLState] : quand le nom commence par plusieurs majuscules, Kotlin les met en minuscules, sauf la dernière lorsqu'elle commence le mot suivant ([SQLState] devient [sqlState]). Depuis Java 6 (JDBC 4.0), [SQLException] a aussi des classes dérivées standard, que les pilotes lancent selon la nature de l'erreur. Les principales sont les suivantes :

Classe
Erreur
[SQLSyntaxErrorException]
erreur de syntaxe SQL, table ou base inexistante, droits insuffisants (SQLSTATE de classe 42)
[SQLIntegrityConstraintViolationException]
violation d'une contrainte d'intégrité : clé en double, clé étrangère, colonne [NOT NULL] (SQLSTATE de classe 23)
[SQLDataException]
donnée incorrecte : valeur trop grande, conversion impossible (SQLSTATE de classe 22)
[SQLTimeoutException]
délai d'exécution dépassé ([queryTimeout])
[SQLTransactionRollbackException]
transaction annulée par le SGBD, par exemple à la suite d'un interblocage (« deadlock »)
[SQLNonTransientConnectionException], [SQLRecoverableException]
problème de connexion

Le programme [Jdbc02.kt] provoque diverses erreurs et les affiche :

package jdbc02

import java.sql.DriverManager
import java.sql.SQLException
import java.sql.SQLIntegrityConstraintViolationException

// la gestion des erreurs d'accès à une base de données

// les paramètres de connexion, en tête de programme
private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

// le numéro de l'erreur MySQL « Duplicate entry »
private const val ER_DUP_ENTRY = 1062

fun main() {
    // des opérations qui échouent, chacune pour une raison différente
    executer("base inexistante", "jdbc:mysql://localhost:3306/dbinconnue", "select * from articles")
    executer("serveur injoignable", "jdbc:mysql://localhost:3399/dbarticles?connectTimeout=5000",
        "select * from articles")
    executer("erreur de syntaxe", URL, "select * fromm articles")
    executer("table inexistante", URL, "select * from article")
    executer("nom en double", URL,
        "insert into articles(nom, prix, stockactuel, stockminimum) values('vélo', 100, 10, 1)")
    executer("prix négatif", URL,
        "insert into articles(nom, prix, stockactuel, stockminimum) values('canoë', -100, 10, 1)")
}

// exécute un ordre SQL et affiche l'erreur éventuelle
private fun executer(titre: String, url: String, requete: String) {
    println("--- $titre")
    try {
        DriverManager.getConnection(url, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
            connexion.createStatement().use { ordre -> ordre.execute(requete) }
        }
        println("pas d'erreur")
    } catch (ex: SQLException) {
        when (ex) {
            // une erreur précise, reconnue par sa classe et son numéro
            is SQLIntegrityConstraintViolationException if ex.errorCode == ER_DUP_ENTRY ->
                println("cet article existe déjà (${ex.message})")
            // les autres erreurs
            else -> {
                println("${ex.javaClass.simpleName} : ErrorCode=${ex.errorCode}, SQLState=${ex.sqlState}")
                println("  ${ex.message?.lineSequence()?.first()}")
            }
        }
    }
}

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc02.Jdbc02Kt
--- base inexistante
SQLSyntaxErrorException : ErrorCode=1049, SQLState=42000
  Unknown database 'dbinconnue'
--- serveur injoignable
CommunicationsException : ErrorCode=0, SQLState=08S01
  Communications link failure
--- erreur de syntaxe
SQLSyntaxErrorException : ErrorCode=1064, SQLState=42000
  You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'fromm articles' at line 1
--- table inexistante
SQLSyntaxErrorException : ErrorCode=1146, SQLState=42S02
  Table 'dbarticles.article' doesn't exist
--- nom en double
cet article existe déjà (Duplicate entry 'vélo' for key 'articles.UNQ_NOM')
--- prix négatif
SQLException : ErrorCode=3819, SQLState=HY000
  Check constraint 'CHK_PRIX' is violated.

Commentaires :

  • lignes 10-12 : les paramètres de connexion à la base, écrits en tête du programme sous la forme de constantes [const val] de haut niveau, privées au fichier ;
  • ligne 15 : le numéro de l'erreur MySQL « Duplicate entry », sous le nom que lui donne la documentation de MySQL ;
  • lignes 19-27 : six opérations qui échouent : base inexistante ; serveur injoignable (aucun serveur MySQL n'écoute sur le port 3399, ce qui produit la même erreur qu'un serveur arrêté) ; erreur de syntaxe ; table inexistante ; nom d'article en double ; prix négatif. Le paramètre [connectTimeout=5000] de l'URL de la ligne 20 limite à 5 secondes (5000 ms) l'attente de la connexion ;
  • ligne 31 : la fonction [executer] exécute l'ordre SQL [requete] sur la base désignée par l'URL [url] ;
  • lignes 34-35 : la connexion et l'ordre SQL sont ouverts par deux [use] imbriqués. Le second tient sur une ligne : la lambda [{ ordre -> ordre.execute(requete) }] n'a qu'une instruction ;
  • ligne 35 : la méthode [execute] exécute un ordre SQL quelconque : [select] ou [insert]. Si elle lance une exception, l'affichage « pas d'erreur » de la ligne 37 n'a pas lieu ;
  • ligne 39 : un [when] sur l'exception, avec des clauses [is Type] (chapitre 3) ;
  • ligne 41 : une garde ([if], Kotlin 2.2) permet de traiter à part une erreur précise : une violation de contrainte d'intégrité ([SQLIntegrityConstraintViolationException]) dont le code MySQL est 1062, c'est-à-dire une clé en double. C'est l'équivalent du motif gardé [case … when …] du [switch] de Java 21. Une application afficherait dans ce cas un message compréhensible par l'utilisateur ;
  • lignes 44-46 : les autres erreurs. On affiche la classe de l'exception ([ex.javaClass.simpleName] : [javaClass] rend l'objet [Class] Java d'un objet), le code d'erreur MySQL et le code SQLSTATE, puis la première ligne du message : certains messages du pilote occupent plusieurs lignes. La propriété [message] est de type [String?] (une exception peut ne pas avoir de message) : on l'exploite avec des appels sûrs, [?.lineSequence()?.first()] ; [lineSequence] découpe la chaîne en une séquence de lignes, dont [first] prend la première. Si le message était [null], l'expression vaudrait [null] et le gabarit afficherait « null ».

Les résultats montrent que le pilote MySQL utilise les classes standard : [SQLSyntaxErrorException] pour une base inexistante (1049), une erreur de syntaxe (1064) ou une table inexistante (1146), toutes de SQLSTATE [42xxx]. Le serveur injoignable produit une [CommunicationsException], une classe du pilote (dérivée de [SQLRecoverableException]) : il n'y a pas de code d'erreur MySQL, puisque le serveur n'a pas répondu, mais le SQLSTATE [08S01] indique un échec de la liaison. La violation d'une contrainte [CHECK] (erreur 3819) est une simple [SQLException], de SQLSTATE [HY000] (« erreur générale ») : MySQL ne lui attribue pas de SQLSTATE de classe 23. On ne peut donc pas se fier entièrement aux classes dérivées : le code d'erreur et le SQLSTATE restent utiles.

Une couche [dao] qui veut rester indépendante du SGBD n'utilise que les classes standard et le SQLSTATE ; les codes d'erreur ([errorCode]) sont propres à chaque SGBD.

Une dernière remarque sur les exceptions contrôlées. Une fonction Kotlin qui laisse passer une [SQLException] ne le déclare nulle part ; si elle doit être appelée depuis du code Java, celui-ci ne pourra pas intercepter l'exception par un [catch (SQLException e)], que le compilateur Java refuse lorsqu'aucune méthode appelée ne la déclare. On ajoute alors à la fonction Kotlin l'annotation [@Throws(SQLException::class)], qui produit la clause [throws] attendue par Java. Nos programmes, entièrement écrits en Kotlin, n'en ont pas besoin.

10.3.5. Configuration du projet exemple

Le pilote JDBC de MySQL est une bibliothèque publiée sur Maven Central, le dépôt de bibliothèques où Gradle va chercher les dépendances (chapitre 2). Le dossier [10-jdbc] du chapitre est une construction Gradle multi-projets (chapitre 2) : les programmes de ce chapitre forment le sous-projet [jdbc], et l'application [Impots] version 6, étudiée en fin de chapitre, deux autres sous-projets :

10-jdbc/
  settings.gradle.kts               la liste des sous-projets
  build.gradle.kts                  le projet racine
  gradlew, gradlew.bat, gradle/     le wrapper de Gradle
  sql/
    dbarticles.sql                  script de création de la base [dbarticles]
    dbimpots.sql                    script de création de la base [dbimpots] (application Impots)
  jdbc/                             le sous-projet des exemples
    build.gradle.kts
    dbarticles.properties           les paramètres de connexion lus par Jdbc01
    src/main/kotlin/
      commun/Article.kt             la classe de données d'une ligne de la table [articles]
      commun/ResultSets.kt          des fonctions d'extension pour lire un ResultSet
      commun/Transactions.kt        une fonction d'extension pour les transactions
      jdbc01/Jdbc01.kt ...          les programmes du chapitre, un paquetage par programme
      modeles/Modeles.kt            les schémas de code du début du chapitre
    src/main/resources/
      logback.xml                   configuration de la journalisation
  ImpotsV6/                         l'application Impots, version 6

Le fichier [settings.gradle.kts] du chapitre déclare les sous-projets :

// chapitre 10 : l'accès aux bases de données avec JDBC

// Gradle télécharge lui-même le JDK demandé par jvmToolchain(25) s'il ne le trouve pas sur le poste
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

rootProject.name = "jdbc-chapitre"

// les programmes JDBC du chapitre
include("jdbc")
// la version 6 de l'application [Impots] : une couche [dao] qui lit une base MySQL
include("ImpotsV6:impots-dao-mysql", "ImpotsV6:impots-ui-mysql")

// les couches [metier] et [ui] de la version 5 (chapitre 6) sont réutilisées : construction composite
includeBuild("../06-architectures")
  • lignes 3-6 : le plugin qui permet à Gradle de télécharger lui-même le JDK 25 s'il ne le trouve pas sur le poste (chapitre 2) ;
  • ligne 11 : le sous-projet [jdbc], dont le dossier porte le même nom ;
  • lignes 13-16 : la version 6 de l'application [Impots], présentée en fin de chapitre.

Le fichier [build.gradle.kts] du sous-projet [jdbc] est le suivant :

// les exemples du chapitre 10 : l'accès aux bases de données avec JDBC
plugins {
    // la version du plugin est donnée par le projet racine
    kotlin("jvm")
    application
}

group = "exemples"
version = "1.0"

repositories {
    mavenCentral()
}

dependencies {
    // le pilote JDBC de MySQL (le programme Jdbc03 utilise l'une de ses classes : il faut [implementation])
    implementation("com.mysql:mysql-connector-j:9.7.0")
    // le pool de connexions HikariCP
    implementation("com.zaxxer:HikariCP:7.0.2")
    // Logback : la journalisation utilisée par HikariCP (au travers de SLF4J)
    runtimeOnly("ch.qos.logback:logback-classic:1.5.38")
    // les coroutines (programme Jdbc08Coroutines)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}

kotlin {
    // le code est compilé et exécuté avec le JDK 25
    jvmToolchain(25)
    compilerOptions {
        // un avertissement du compilateur est traité comme une erreur
        allWarningsAsErrors = true
        // les noms des paramètres des fonctions sont conservés dans le bytecode
        javaParameters = true
    }
}

application {
    // le programme exécuté par [gradlew run] est désigné par la propriété [main] :
    // gradlew -q :jdbc:run -Pmain=jdbc02.Jdbc02Kt
    mainClass = providers.gradleProperty("main").orElse("jdbc01.Jdbc01Kt")
}

tasks.named<JavaExec>("run") {
    // le programme exécuté peut lire le clavier
    standardInput = System.`in`
}

// sous Windows, les sorties écran du programme lancé par Gradle (run, bootRun...) sont encodées en UTF-8,
// sinon les caractères accentués sont abîmés dans la console d'IntelliJ IDEA ou le terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lignes 2-5 : le greffon Kotlin pour la JVM, dont la version est fixée par le projet racine (chapitre 2), et le greffon [application], qui fournit la tâche [run] ;
  • lignes 15-23 : les quatre bibliothèques du projet, téléchargées par Gradle depuis Maven Central lors de la première construction :
    • ligne 17 : le pilote JDBC de MySQL, MySQL Connector/J, de coordonnées [com.mysql:mysql-connector-j]. Jusqu'à sa version 8.0.30, il était publié sous les coordonnées [mysql:mysql-connector-java], que l'on rencontre encore dans de nombreux projets. Un programme qui n'utilise que les interfaces de JDBC n'a besoin du pilote qu'à l'exécution : on le déclare alors avec [runtimeOnly], ce qui garantit qu'aucune classe du pilote ne peut être utilisée par erreur dans le code. Nous le déclarons avec [implementation] parce que le programme [Jdbc03] utilise une classe du pilote, [MysqlDataSource], pour une démonstration ;
    • ligne 18 : le pool de connexions HikariCP, que nous utiliserons à partir du programme [Jdbc03] ;
    • ligne 21 : HikariCP écrit ses messages de journalisation au travers de l'API SLF4J (« Simple Logging Facade for Java »), qui a besoin d'une implémentation : nous utilisons Logback, nécessaire seulement à l'exécution ([runtimeOnly]). Sans elle, SLF4J afficherait un avertissement au démarrage. Le fichier [src/main/resources/logback.xml], présenté plus bas, n'affiche que les avertissements et les erreurs ;
    • ligne 23 : la bibliothèque des coroutines, kotlinx-coroutines (chapitre 9), utilisée par le programme [Jdbc08Coroutines], qui exécute des requêtes JDBC dans des coroutines ;
  • lignes 27-33 : le compilateur, configuré comme dans tous les projets du cours : JDK 25, avertissements traités comme des erreurs, noms des paramètres conservés dans les classes compilées ;
  • lignes 38-40 : le programme exécuté par [gradlew run] est désigné par la propriété [main] ; par défaut, c'est [jdbc01.Jdbc01Kt] ;
  • lignes 43-45 : le programme exécuté peut lire le clavier, ce dont [Jdbc01] a besoin ;
  • lignes 48-51 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).

Le fichier de configuration de Logback est le suivant :

<?xml version="1.0" encoding="UTF-8"?>
<!-- configuration de Logback : seuls les avertissements et les erreurs sont affichés -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger{0} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • lignes 4-8 : les messages sont écrits sur la console, sous la forme « niveau classe - message » ;
  • ligne 9 : seuls les messages de niveau [WARN] (avertissement) ou [ERROR] sont affichés. Avec le niveau [INFO], on verrait les messages de démarrage et d'arrêt du pool de HikariCP.

Il existe deux pilotes JDBC courants pour MySQL :

  • [com.mysql:mysql-connector-j] (« Connector/J »), publié par Oracle, propriétaire de MySQL, sous licence GPL v2 avec une exception (« Universal FOSS Exception ») qui permet de l'associer à des logiciels distribués sous d'autres licences libres ; une licence commerciale couvre les autres cas. C'est le pilote officiel, que nous utilisons ;
  • [org.mariadb.jdbc:mariadb-java-client], le pilote de MariaDB (une variante libre de MySQL), sous licence LGPL, qui fonctionne aussi avec MySQL.

Les deux pilotes implémentent les mêmes interfaces JDBC : passer de l'un à l'autre ne demande que de changer la dépendance du [build.gradle.kts] et le début de l'URL ([jdbc:mariadb:] au lieu de [jdbc:mysql:]).

Une remarque historique : jusqu'à Java 5, il fallait charger la classe du pilote avant d'ouvrir une connexion, par une instruction comme [Class.forName("com.mysql.jdbc.Driver")], que l'on trouve encore dans de nombreux exemples sur Internet. Depuis Java 6 (JDBC 4.0), c'est inutile : chaque archive de pilote déclare sa classe dans un fichier [META-INF/services/java.sql.Driver], et [DriverManager] charge automatiquement, par le mécanisme des services ([ServiceLoader]), tous les pilotes présents dans le classpath.

Les paramètres de connexion ne doivent pas être écrits dans le code d'une application réelle : ils dépendent de la machine sur laquelle l'application est installée. Le premier programme du chapitre les lit dans un fichier de propriétés (chapitre 6), [dbarticles.properties], placé dans le dossier du sous-projet :

1
2
3
4
# paramètres de connexion à la base [dbarticles]
url=jdbc:mysql://localhost:3306/dbarticles
utilisateur=root
motdepasse=
  • ligne 2 : l'URL JDBC de la base. Une URL JDBC a la forme [jdbc:sous-protocole:détails]. Le sous-protocole désigne le pilote ([mysql]) et les détails, dont la forme dépend du pilote, désignent la base. Pour MySQL, ce sont :
    • [//localhost] : le nom de la machine sur laquelle se trouve le SGBD MySQL, ici [localhost], c'est-à-dire la machine sur laquelle s'exécute le programme ;
    • [:3306] : le port sur lequel le serveur MySQL attend les connexions, 3306 par défaut ;
    • [/dbarticles] : le nom de la base de données ;
    • des paramètres facultatifs du pilote peuvent suivre, après un point d'interrogation, sous la forme [?param1=valeur1&param2=valeur2]. Nous rencontrerons [connectTimeout], [rewriteBatchedStatements] et [allowMultiQueries] ;
  • ligne 3 : l'utilisateur MySQL, ici [root], l'administrateur du SGBD ;
  • ligne 4 : son mot de passe, vide ici.

La documentation de Connector/J donne la liste complète des paramètres de l'URL : Connector/J Configuration Properties.

Les autres programmes du chapitre, plus courts, écrivent leurs paramètres de connexion dans des constantes en tête du programme, comme [Jdbc02.kt] : c'est plus simple pour des exemples, à ne pas faire dans une application.

Pour exécuter les programmes, on se place dans le dossier [10-jdbc] et on tape :

> gradlew -q :jdbc:run
> gradlew -q :jdbc:run -Pmain=jdbc02.Jdbc02Kt

La première commande exécute [Jdbc01], la seconde [Jdbc02]. Le préfixe [:jdbc:] désigne la tâche [run] du sous-projet [jdbc] ; la tâche [run] s'exécute dans le dossier du sous-projet, [10-jdbc/jdbc], où se trouve le fichier [dbarticles.properties]. L'option [-q] (« quiet ») supprime les messages de Gradle : seule reste la sortie du programme. On peut aussi, bien sûr, exécuter les programmes depuis IntelliJ IDEA, par le triangle vert placé à côté de la fonction [main] (le dossier courant est alors à vérifier dans la configuration d'exécution). Dans la suite, nous ne reproduisons que la sortie des programmes.

10.3.6. Le programme exemple

Le programme [Jdbc01.kt] dont nous avons vu un exemple d'exécution est le suivant (programme principal) :

package jdbc01

import java.io.IOException
import java.nio.file.Path
import java.sql.Connection
import java.sql.DriverManager
import java.sql.ResultSet
import java.sql.SQLException
import java.util.Properties
import kotlin.io.path.bufferedReader

// application console : exécute des ordres SQL tapés au clavier
// sur une base de données dont les paramètres de connexion sont lus dans un fichier de configuration

// les ordres SQL acceptés
private val COMMANDES_SQL = setOf("select", "insert", "update", "delete")

// les paramètres de connexion à une base de données
data class ParametresConnexion(val url: String, val utilisateur: String, val motDePasse: String) {
    // une nouvelle connexion à la base
    fun connecter(): Connection = DriverManager.getConnection(url, utilisateur, motDePasse)
}

// lecture des paramètres de connexion dans un fichier de propriétés
fun lireConfiguration(fichier: String): ParametresConnexion {
    val config = Properties()
    Path.of(fichier).bufferedReader().use { config.load(it) }
    val url = config.getProperty("url") ?: throw IOException("le paramètre [url] est absent")
    return ParametresConnexion(url, config.getProperty("utilisateur", ""), config.getProperty("motdepasse", ""))
}

fun main() {
    // lecture des paramètres de connexion dans [dbarticles.properties] (dossier courant)
    val parametres = try {
        lireConfiguration("dbarticles.properties")
    } catch (e: IOException) {
        println("Erreur de configuration : $e")
        return
    }

    // affichage des paramètres de connexion
    println("URL JDBC de la base : [${parametres.url}], utilisateur : [${parametres.utilisateur}]")

    // boucle de saisie-exécution des ordres SQL tapés au clavier
    while (true) {
        // demande de la requête
        print("\nRequête SQL (rien pour arrêter) : ")
        val requete = readlnOrNull()?.trim().orEmpty()
        // fini ?
        if (requete.isEmpty()) break
        // le premier mot de la requête doit être l'un des quatre ordres acceptés
        val premierMot = requete.split(Regex("\\s+")).first().lowercase()
        if (premierMot !in COMMANDES_SQL) {
            println("Requête invalide. Utilisez select, insert, update, delete ou rien pour arrêter")
            continue
        }
        // exécution de la requête
        if (premierMot == "select") {
            executeSelect(parametres, requete)
        } else {
            executeUpdate(parametres, requete)
        }
    }
}
  • lignes 5-8 : les interfaces et classes JDBC utilisées, toutes du paquetage [java.sql]. Le programme ne contient le nom d'aucune classe du pilote MySQL : seule l'URL JDBC, lue dans la configuration, désigne MySQL ;
  • ligne 16 : les quatre ordres SQL acceptés, dans un ensemble en lecture seule créé par [setOf] (chapitre 5) ;
  • lignes 19-21 : les trois paramètres de connexion sont réunis dans une classe de données. Plutôt que de passer trois chaînes à chaque fonction, comme le fait le programme Java du cours, on passe un objet [ParametresConnexion], qui sait ouvrir une connexion : sa méthode [connecter] appelle [DriverManager.getConnection] avec ses propres propriétés. Le type rendu, [Connection], est écrit explicitement : sans lui, la fonction rendrait le type de plate-forme [Connection!] ;
  • lignes 25-29 : la lecture du fichier de propriétés, comme au chapitre 6. La fonction d'extension [bufferedReader] de [kotlin.io.path] ouvre le fichier en UTF-8 et [use] le ferme. [getProperty] rend la valeur d'une clé, ou [null] si la clé est absente : son type Kotlin est [String?]. L'URL étant obligatoire, l'opérateur Elvis [?:] lance une [IOException] lorsqu'elle manque, ce qui permet de traiter de la même façon un fichier absent et un fichier incomplet ; [getProperty(clé, défaut)] rend une valeur par défaut pour les deux autres paramètres ;
  • ligne 32 : la fonction [main] de haut niveau, sans paramètre puisque le programme n'utilise pas ses arguments ;
  • lignes 34-38 : [try] est une expression (chapitre 3) : sa valeur est celle du bloc [try] si tout se passe bien ; en cas d'erreur, la clause [catch] affiche un message et termine le programme par [return]. Le fichier est cherché dans le dossier courant, celui à partir duquel le programme est lancé : c'est le dossier du sous-projet lorsqu'on tape [gradlew :jdbc:run] ;
  • ligne 45 : la boucle de saisie des ordres SQL tapés au clavier et de leur exécution sur la base de données ;
  • lignes 47-48 : [print] affiche l'invite sans passer à la ligne, puis [readlnOrNull] lit une ligne tapée au clavier ; elle rend [null] si l'entrée standard est fermée. L'appel sûr [?.trim()] suivi de [orEmpty] transforme ce [null] en chaîne vide, ce qui arrête la boucle à la ligne suivante ;
  • ligne 52 : le premier mot de la requête, obtenu en découpant la requête sur les espaces (expression régulière [\s+], chapitre 5). Il est mis en minuscules pour la comparaison, mais la requête elle-même n'est pas modifiée : la mettre tout entière en minuscules empêcherait d'insérer un article nommé « Vélo » ;
  • lignes 53-55 : si la requête est invalide, un message d'erreur est affiché et on passe à la requête suivante. L'opérateur [!in] appelle la méthode [contains] de l'ensemble (chapitre 4) ;
  • lignes 58-61 : l'exécution de l'ordre SQL saisi. Elle prend une forme différente selon qu'il s'agit d'un ordre [select] ou d'un ordre [insert], [update], [delete] : dans le premier cas, l'ordre ramène des données de la base sans la modifier ; dans le second, il met la base à jour sans ramener de données. Dans les deux cas, l'exécution est déléguée à une fonction qui reçoit les paramètres de connexion et l'ordre SQL à exécuter.

On remarquera l'absence de toute déclaration d'exception : en Java, la lecture du fichier oblige à intercepter ou déclarer l'[IOException] contrôlée. En Kotlin, c'est un choix : nous interceptons l'[IOException] ici parce que c'est l'endroit où l'on sait quoi faire de l'erreur (prévenir l'utilisateur et s'arrêter).

10.3.7. Exécution d'une requête SELECT

L'exécution d'ordres SQL comporte les étapes suivantes :

  • connexion à la base de données ;
  • émission des ordres SQL vers la base ;
  • traitement des résultats des ordres SQL ;
  • fermeture de la connexion.

Les étapes 2 et 3 peuvent être répétées, la fermeture de la connexion n'ayant lieu qu'à la fin de l'exploitation de la base. Les connexions ouvertes sont des ressources limitées d'un SGBD : il faut les économiser, et l'on cherche toujours à limiter la durée de vie d'une connexion ouverte. Dans notre programme, la connexion est fermée après chaque ordre SQL, et une nouvelle connexion est ouverte pour l'ordre suivant.

L'ouverture d'une connexion est pourtant coûteuse : il faut établir une connexion réseau avec le serveur, négocier éventuellement un chiffrement, s'authentifier. [DriverManager] ne fait rien pour diminuer ce coût : chaque appel à [getConnection] ouvre une nouvelle connexion réseau, et chaque [close] la ferme réellement. Pour un outil interactif comme celui-ci, ce n'est pas gênant. Une application qui exécute de nombreux ordres SQL, un serveur web par exemple, utilise au contraire un pool de connexions : lorsque l'application ferme une connexion, le pool ne la ferme pas réellement mais la remet dans un réservoir (le pool) ; lorsque l'application demande une nouvelle connexion, elle reçoit l'une des connexions du pool, déjà établie. L'intérêt de ce système est qu'il est transparent pour le développeur : le code qui ouvre et ferme ses connexions par un [use] n'a pas à être modifié. Nous verrons au paragraphe « Les sources de données et les pools de connexions » comment l'utiliser. Ouvrir une connexion au début de chaque opération et la fermer à la fin est donc la bonne pratique.

Nous nous intéressons tout d'abord à l'exécution des ordres SQL [select]. La fonction [executeSelect] de notre programme exemple et la fonction [afficherResultSet] qu'elle utilise sont les suivantes :

// exécution d'un ordre select
private fun executeSelect(parametres: ParametresConnexion, requete: String) {
    try {
        // la connexion, l'ordre SQL et le résultat sont fermés à la sortie de leur bloc [use]
        parametres.connecter().use { connexion ->
            connexion.createStatement().use { ordre ->
                ordre.executeQuery(requete).use { resultat ->
                    // affichage des résultats
                    afficherResultSet(resultat)
                }
            }
        }
    } catch (e: SQLException) {
        println("Erreur d'accès à la base de données (${e.message})")
    }
}

// affichage des lignes d'un résultat de select
private fun afficherResultSet(resultat: ResultSet) {
    // les noms des colonnes, donnés par les métadonnées du résultat
    val metadonnees = resultat.metaData
    val colonnes = 1..metadonnees.columnCount
    val entete = colonnes.joinToString(",") { metadonnees.getColumnLabel(it) }
    val tirets = "-".repeat(entete.length)
    println("\n$tirets\n$entete\n$tirets\n")
    // les lignes, l'une après l'autre
    while (resultat.next()) {
        println(colonnes.joinToString(" ") { "${resultat.getObject(it)}" })
    }
}
  • ligne 2 : la fonction reçoit les paramètres de connexion et l'ordre SQL [select] [requete] à exécuter. Comme les deux autres fonctions du programme, elle est déclarée [private] : elle n'est visible que dans le fichier [Jdbc01.kt] ;
  • lignes 5-7 : trois [use] imbriqués ouvrent trois ressources :
    • ligne 5 : la connexion avec la base, obtenue de [DriverManager] par la méthode [connecter]. [DriverManager] interroge chacun des pilotes du classpath : le premier qui reconnaît l'URL ouvre la connexion. C'est à ce moment que les paramètres de connexion sont utilisés, et que se produisent les erreurs de connexion. La connexion est du type de l'interface [Connection] : le code ne sait pas qu'il s'agit d'un objet du pilote MySQL ;
    • ligne 6 : l'ordre SQL, un objet [Statement] créé par la connexion. Le même type d'objet sert à exécuter un ordre [select] et un ordre [update], [insert], [delete] ;
    • ligne 7 : l'ordre [select] est exécuté par la méthode [executeQuery], qui rend un objet [ResultSet] ;
  • ligne 9 : l'affichage des résultats est confié à la fonction [afficherResultSet] ;
  • lignes 13-14 : toute opération avec une base de données peut lancer une [SQLException]. C'est d'autant plus probable ici que les ordres SQL donnés par l'utilisateur peuvent être syntaxiquement erronés. Il faut qu'on puisse le lui dire : l'exception est interceptée et son message affiché. Au moment où la clause [catch] s'exécute, que l'exception vienne de la connexion, de l'exécution ou de la lecture des lignes, les [use] ont déjà fermé le résultat, l'ordre SQL et la connexion ;
  • ligne 19 : la fonction reçoit un objet [ResultSet]. Ses méthodes peuvent lancer une [SQLException] ; en Java, la méthode correspondante doit le déclarer par [throws SQLException]. En Kotlin, rien à déclarer : l'exception remonte simplement à la fonction appelante, qui l'intercepte ;
  • lignes 21-25 : on affiche les noms des colonnes de la table résultat du [select]. Ce sont les colonnes [coli] de la requête [select col1, col2, … from table …]. Ils sont donnés par les métadonnées du résultat, un objet [ResultSetMetaData] obtenu par la propriété [metaData] : [columnCount] donne le nombre de colonnes et [getColumnLabel(i)] le nom de la colonne n° i, ou son alias si la requête en donne un ([select count(*) as nombre …]). Les colonnes étant numérotées à partir de 1, on construit l'intervalle [1..metadonnees.columnCount] (ligne 22), puis [joinToString] (chapitre 5) transforme chaque numéro en nom et assemble les noms en les séparant par des virgules. Aucun tableau intermédiaire n'est nécessaire, contrairement à la version Java. ["-".repeat(n)] produit une chaîne de n tirets ;
  • lignes 27-28 : on parcourt la table résultat et on affiche les valeurs de chaque ligne, séparées par des espaces, avec le même intervalle [colonnes] et la même fonction [joinToString] ;
  • ligne 28 : on ne connaît pas le type de la colonne n° i du résultat parce qu'on ne connaît pas la requête tapée par l'utilisateur. On ne peut donc pas utiliser la syntaxe [resultat.getXxx(i)], où [Xxx] est le type de la colonne. On utilise alors [getObject(i)], qui rend la valeur sous la forme d'un [Any!] ; le gabarit ["${resultat.getObject(it)}"] la convertit en chaîne au moyen de sa méthode [toString] (et affiche « null » pour une valeur SQL [NULL]). Pour la colonne [prix], de type SQL [DECIMAL(10,2)], l'objet est un [BigDecimal] de deux décimales, que sa méthode [toString] affiche avec un point décimal ([500.00]) : contrairement à [String.format], [toString] ne dépend pas de la langue du poste. Un affichage « à la française » utiliserait ["%.2f".format(prix)] ou la classe [NumberFormat] (chapitre 5).

10.3.8. Exécution d'un ordre de mise à jour : INSERT, UPDATE, DELETE

Le code de la fonction [executeUpdate] est le suivant :

// exécution d'un ordre de mise à jour (insert, update, delete)
private fun executeUpdate(parametres: ParametresConnexion, requete: String) {
    try {
        parametres.connecter().use { connexion ->
            connexion.createStatement().use { ordre ->
                // exécution de l'ordre SQL
                val nbLignes = ordre.executeUpdate(requete)
                // affichage du résultat
                println("Il y a eu $nbLignes ligne(s) modifiée(s)")
            }
        }
    } catch (e: SQLException) {
        println("Erreur d'accès à la base de données (${e.message})")
    }
}

L'exécution d'un ordre de mise à jour ne diffère de celle d'un ordre [select] que par la méthode de l'ordre SQL utilisée : [executeQuery] pour [select], [executeUpdate] pour [update], [insert], [delete] :

  • lignes 4-5 : il n'y a cette fois que deux ressources : la connexion et l'ordre SQL ;
  • ligne 7 : l'ordre [update], [insert] ou [delete] est exécuté par la méthode [executeUpdate]. Si elle réussit, cette méthode rend le nombre de lignes mises à jour ([update]), insérées ([insert]) ou supprimées ([delete]) ;
  • ligne 9 : ce nombre de lignes est affiché.

Le lecteur est invité à revoir l'exemple d'exécution donné au paragraphe « Les quatre commandes de base du langage SQL ».

10.4. JDBC et l'indépendance vis-à-vis du SGBD

10.4.1. Les pilotes JDBC actuels

Le code que nous avons étudié ne contient le nom d'aucune classe du pilote MySQL : il n'utilise que les interfaces de [java.sql]. Pour le faire travailler avec un autre SGBD, il suffit en principe d'ajouter au projet le pilote de ce SGBD et de changer l'URL JDBC. Voici les principaux pilotes JDBC d'aujourd'hui, tous publiés sur Maven Central :

SGBD
Dépendance Gradle
URL JDBC
Remarques
MySQL
[com.mysql:mysql-connector-j]
[jdbc:mysql://hôte:3306/base]
le pilote officiel, utilisé dans ce cours
MariaDB, MySQL
[org.mariadb.jdbc:mariadb-java-client]
[jdbc:mariadb://hôte:3306/base]
 
PostgreSQL
[org.postgresql:postgresql]
[jdbc:postgresql://hôte:5432/base]
 
SQL Server, Azure SQL
[com.microsoft.sqlserver:mssql-jdbc]
[jdbc:sqlserver://hôte:1433;databaseName=base]
le pilote de Microsoft
Oracle
[com.oracle.database.jdbc:ojdbc11]
[jdbc:oracle:thin:@//hôte:1521/service]
 
H2
[com.h2database:h2]
[jdbc:h2:mem:base] ou [jdbc:h2:./fichier]
un SGBD écrit en Java, embarqué dans l'application : base en mémoire ou dans un fichier, sans serveur ; très utilisé pour les tests
SQLite
[org.xerial:sqlite-jdbc]
[jdbc:sqlite:fichier.db]
une base dans un fichier, sans serveur

On remarquera que la forme de l'URL, après [jdbc:], est propre à chaque pilote.

L'architecture est la suivante :

Image

L'application ne connaît que les interfaces de JDBC. Les objets concrets sont créés par le pilote, que [DriverManager] choisit d'après l'URL : chaque pilote présent dans le classpath déclare sa classe principale, une implémentation de l'interface [java.sql.Driver], dont la méthode [connect(url, propriétés)] rend [null] si l'URL ne lui est pas destinée ([acceptsURL(url)] permet aussi de le lui demander). Le programme [Jdbc03.kt] le montre. Il reçoit en argument l'URL JDBC de la base :

package jdbc03

import com.mysql.cj.jdbc.MysqlDataSource
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
import javax.sql.DataSource
import kotlin.time.measureTime

// JDBC est une abstraction : le code n'utilise que les interfaces de java.sql et javax.sql
// le pilote effectivement utilisé est choisi par l'URL JDBC (1er argument)

fun main(args: Array<String>) {
    // l'URL JDBC de la base
    val url = args.firstOrNull() ?: "jdbc:mysql://localhost:3306/dbarticles"

    // 1 - les pilotes JDBC trouvés dans le classpath
    println("Pilotes JDBC chargés :")
    for (pilote in DriverManager.drivers()) {
        println("  ${pilote.javaClass.name} ${pilote.majorVersion}.${pilote.minorVersion}")
    }

    // 2 - DriverManager choisit le pilote qui accepte l'URL
    try {
        DriverManager.getConnection(url, "root", "").use { connexion ->
            val metadonnees = connexion.metaData
            println("SGBD : ${metadonnees.databaseProductName} ${metadonnees.databaseProductVersion}")
            println("Pilote : ${metadonnees.driverName} ${metadonnees.driverVersion} " +
                    "(JDBC ${metadonnees.jdbcMajorVersion}.${metadonnees.jdbcMinorVersion})")
            afficherArticles(connexion)
        }
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
        return
    }

    // 3 - une source de données sans pool : chaque getConnection ouvre une connexion réseau
    val sansPool = MysqlDataSource().apply {
        setURL(url)
        user = "root"
        password = ""
    }
    mesurer("MysqlDataSource (sans pool)", sansPool)

    // 4 - une source de données avec un pool de connexions HikariCP
    val config = HikariConfig().apply {
        jdbcUrl = url
        username = "root"
        password = ""
        maximumPoolSize = 5
    }
    HikariDataSource(config).use { avecPool ->
        mesurer("HikariDataSource (pool)", avecPool)
    }
}

// une requête paramétrée exécutée au travers des seules interfaces de JDBC
private fun afficherArticles(connexion: Connection) {
    connexion.prepareStatement("select id, nom, prix from articles where prix > ?").use { ordre ->
        ordre.setInt(1, 200)
        ordre.executeQuery().use { resultat ->
            println("Objets créés : ${connexion.javaClass.simpleName}, ${ordre.javaClass.simpleName}, " +
                    resultat.javaClass.simpleName)
            while (resultat.next()) {
                println("  ${resultat.getInt(1)} ${resultat.getString(2)} ${resultat.getBigDecimal(3)}")
            }
        }
    }
}

// 100 ouvertures / fermetures de connexion obtenues d'une source de données
private fun mesurer(titre: String, source: DataSource) {
    var classe = ""
    try {
        val duree = measureTime {
            repeat(100) {
                source.connection.use { connexion -> classe = connexion.javaClass.simpleName }
            }
        }
        println("$titre : 100 connexions $classe en ${duree.inWholeMilliseconds} ms")
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

Résultats avec l'URL de MySQL (valeur par défaut) :

> gradlew -q :jdbc:run -Pmain=jdbc03.Jdbc03Kt
Pilotes JDBC chargés :
  com.mysql.cj.jdbc.Driver 9.7
SGBD : MySQL 8.0.46-0ubuntu0.24.04.4
Pilote : MySQL Connector/J mysql-connector-j-9.7.0 (Revision: 0aade1f13bcc98faf7dda5c02e782481eb291f62) (JDBC 4.2)
Objets créés : ConnectionImpl, ClientPreparedStatement, ResultSetImpl
  1 vélo 500.00
  3 arc 600.00
  5 combinaison de plongée 300.00
MysqlDataSource (sans pool) : 100 connexions ConnectionImpl en 3293 ms
HikariDataSource (pool) : 100 connexions HikariProxyConnection en 40 ms

Résultats avec l'URL d'une base PostgreSQL, dont le pilote n'est pas dans le classpath :

1
2
3
4
> gradlew -q :jdbc:run -Pmain=jdbc03.Jdbc03Kt --args="jdbc:postgresql://localhost:5432/dbarticles"
Pilotes JDBC chargés :
  com.mysql.cj.jdbc.Driver 9.7
Erreur d'accès à la base de données (No suitable driver found for jdbc:postgresql://localhost:5432/dbarticles)

Nous commentons ici la première partie du programme (fonction [main] jusqu'à la ligne 36, et fonction [afficherArticles]) ; la seconde est étudiée au paragraphe suivant :

  • ligne 17 : l'URL JDBC de la base, passée en argument, celle de la base MySQL [dbarticles] par défaut. [firstOrNull] rend le premier élément du tableau des arguments, ou [null] s'il est vide ; l'opérateur Elvis [?:] remplace alors [null] par l'URL par défaut. Avec Gradle, l'argument est passé par l'option [--args] ;
  • lignes 21-22 : [DriverManager.drivers] (Java 9) rend un flux ([Stream]) des pilotes chargés. Un flux Java n'est pas une collection Kotlin, mais il a une méthode [iterator()] : cela suffit pour que la boucle [for] de Kotlin puisse le parcourir. Pour chaque pilote, [pilote.javaClass.name] donne le nom complet de sa classe, et les propriétés [majorVersion] et [minorVersion] (les méthodes Java [getMajorVersion] et [getMinorVersion]) sa version. Un seul pilote est présent dans le classpath : celui de MySQL, de classe [com.mysql.cj.jdbc.Driver], en version 9.7 (ligne 3 des résultats) ;
  • ligne 27 : [DriverManager] cherche un pilote qui accepte l'URL. Si aucun ne l'accepte, [getConnection] lance une [SQLException] « No suitable driver found » (ligne 4 des seconds résultats). C'est l'erreur qu'on obtient lorsqu'on a oublié la dépendance du pilote dans le [build.gradle.kts], ou fait une faute de frappe dans l'URL ;
  • lignes 28-31 : les métadonnées de la connexion, un objet [DatabaseMetaData], décrivent le SGBD (nom, version), le pilote et la version de JDBC qu'il implémente : JDBC 4.2, la version de Java 8. Le JDK 25 définit JDBC 4.3 (Java 9), dont les ajouts sont mineurs ; [DatabaseMetaData] décrit aussi les possibilités du SGBD (transactions, lots…), ses tables et leurs colonnes. Toutes ces informations sont obtenues par des méthodes [getXxx] sans paramètre, que Kotlin présente comme des propriétés ([databaseProductName], [driverVersion]…). La propriété [jdbcMajorVersion] correspond à la méthode Java [getJDBCMajorVersion] : comme pour [sqlState], le groupe de majuscules initial est mis en minuscules ;
  • ligne 32 : l'exécution d'une requête paramétrée par la fonction [afficherArticles] ;
  • lignes 61-62 : la requête a un paramètre, noté [?], qui reçoit sa valeur par [setInt(1, 200)]. Les requêtes paramétrées sont étudiées plus loin ;
  • lignes 64-65 : les résultats montrent que les objets manipulés au travers des interfaces sont des objets du pilote MySQL : [ConnectionImpl], [ClientPreparedStatement], [ResultSetImpl] (ligne 6 des résultats) ;
  • lignes 66-67 : le type des colonnes étant connu, on utilise les méthodes [getInt], [getString], [getBigDecimal], qui rendent directement une valeur du type attendu.

Pour que ce programme fonctionne avec PostgreSQL, il suffirait d'ajouter au [build.gradle.kts] la dépendance [runtimeOnly("org.postgresql:postgresql:…")] et de créer la base [dbarticles] dans PostgreSQL : [DriverManager] trouverait alors deux pilotes et choisirait celui de PostgreSQL pour une URL [jdbc:postgresql:…]. Seul MySQL est installé sur la machine qui a servi à écrire ce cours : nous ne l'avons pas fait.

10.4.2. Les sources de données et les pools de connexions

[DriverManager] est la façon historique d'obtenir une connexion. Depuis JDBC 2.0, on lui préfère dans les applications une source de données, un objet qui implémente l'interface [javax.sql.DataSource]. Une source de données est configurée une fois pour toutes (URL, utilisateur, mot de passe, réglages du pilote) et fournit ensuite des connexions par sa méthode [getConnection()], sans paramètres (la propriété [connection] en Kotlin). Le code qui utilise la source de données ne connaît ni l'URL ni les identifiants : dans une application, c'est elle que l'on enregistre dans le conteneur d'injection de dépendances (chapitre 6), plutôt que les paramètres de connexion. Il existe deux grandes familles de sources de données :

  • celles fournies par les pilotes, comme [com.mysql.cj.jdbc.MysqlDataSource] pour MySQL. Comme [DriverManager], elles ouvrent une nouvelle connexion réseau à chaque appel de [getConnection] ;
  • celles qui gèrent un pool de connexions. La plus utilisée est HikariCP, le pool par défaut de Spring Boot. Son [getConnection] rend une connexion du pool, et le [close] de cette connexion la remet dans le pool au lieu de la fermer.

La seconde partie du programme [Jdbc03] compare les deux (lignes 39-55 et fonction [mesurer]) :

  • lignes 40-43 : une source de données [MysqlDataSource]. La fonction de portée [apply] (chapitre 7) exécute le bloc avec l'objet créé comme receveur ([this]) puis rend cet objet : on peut ainsi configurer l'objet juste après sa création, sans répéter son nom. [user = "root"] appelle la méthode Java [setUser], et [password = ""] la méthode [setPassword]. Pour l'URL, il faut appeler [setURL(url)] : la classe [MysqlDataSource] a deux couples d'accesseurs, [getUrl] / [setUrl] et [getURL] / [setURL], qui deviendraient tous deux une propriété Kotlin [url] ; le compilateur refuse alors d'écrire [url = …] (« overload resolution ambiguity between candidates »). C'est la seule partie du programme qui nomme une classe du pilote MySQL : c'est pour elle que le pilote est déclaré par [implementation] dans le [build.gradle.kts] ;
  • ligne 45 : la fonction [mesurer] reçoit la source de données sous le type de l'interface [DataSource] : elle conviendrait à n'importe quel pilote ;
  • lignes 48-52 : un objet [HikariConfig] réunit la configuration du pool : URL JDBC, identifiants et taille maximale du pool (5 connexions ; la valeur par défaut est 10). Il est lui aussi configuré par [apply], avec ses propriétés [jdbcUrl], [username], [password] et [maximumPoolSize]. HikariCP utilise [DriverManager] pour ouvrir ses connexions : il fonctionne avec n'importe quel pilote JDBC ;
  • ligne 54 : la source de données [HikariDataSource] est créée à partir de cette configuration. Elle ouvre aussitôt ses premières connexions. Comme elle détient des connexions ouvertes, il faut la fermer à la fin du programme : [HikariDataSource] implémente [Closeable], et [use] s'en charge ;
  • lignes 74-82 : la fonction [mesurer] demande 100 fois une connexion à la source de données ([repeat(100) { … }]) et la ferme aussitôt. La fonction [measureTime] de [kotlin.time] exécute le bloc qu'elle reçoit et rend sa durée, un objet [Duration], dont [inWholeMilliseconds] donne le nombre de millisecondes (en Java, on calcule soi-même la différence de deux appels à [System.nanoTime]). Le bloc modifie la variable [classe], déclarée par [var] hors du bloc : les lambdas de Kotlin peuvent modifier les variables qu'elles capturent (chapitre 7), ce que Java interdit. Le résultat est éloquent (lignes 10-11 des premiers résultats) : sans pool, l'ouverture d'une connexion demande plusieurs dizaines de millisecondes, pour la connexion réseau et l'authentification ; avec le pool, la connexion est obtenue en une fraction de milliseconde. Les durées dépendent de la machine et varient d'une exécution à l'autre, mais le rapport reste de cet ordre ;
  • ligne 79 : avec HikariCP, la connexion rendue est un objet [HikariProxyConnection] : un « mandataire » (« proxy ») qui enveloppe la connexion du pilote et dont la méthode [close] la rend au pool.

Une application qui sert de nombreux utilisateurs utilise donc toujours un pool de connexions. Nous en reverrons un usage au paragraphe sur les threads virtuels.

10.4.3. Quel niveau d'abstraction choisir ?

L'indépendance de JDBC vis-à-vis du SGBD semble idéale. Dans la pratique, JDBC n'arrive pas à cacher toutes les particularités d'un SGBD :

  • le SQL lui-même diffère d'un SGBD à l'autre dès qu'on sort des ordres de base : génération des clés primaires, limitation du nombre de lignes rendues ([limit 10] pour MySQL et PostgreSQL, [top 10] ou [offset … fetch] pour SQL Server), types des colonnes, fonctions de dates, etc. ;
  • les types rendus par [getObject] peuvent varier : nous verrons que le [count(*)] de MySQL rend un [Long] ;
  • les codes d'erreur ([errorCode]) sont propres à chaque SGBD, et les SQLSTATE ne sont pas toujours utilisés de la même façon ;
  • les URL et les paramètres de réglage sont propres à chaque pilote.

Les paramètres des requêtes, en revanche, sont uniformes : JDBC impose la notation [?] (des paramètres positionnels, sans nom), que chaque pilote traduit dans la syntaxe de son SGBD.

Pour s'affranchir de ces limites ou alléger l'écriture du code JDBC, on utilise aujourd'hui principalement :

  • un ORM, le plus souvent Hibernate, présenté au chapitre 11 : la couche [dao] manipule des objets et écrit ses requêtes en HQL, un langage de requêtes objet ; c'est Hibernate qui génère le SQL propre à chaque SGBD (son « dialecte ») ;
  • des bibliothèques qui gardent le SQL mais suppriment le code répétitif de JDBC : [JdbcTemplate] et [JdbcClient] de Spring (ce dernier depuis Spring Framework 6.1), qui transforment directement les lignes d'un [select] en objets et convertissent les [SQLException] en exceptions non contrôlées ; jOOQ, qui construit les requêtes SQL dans le code avec vérification par le compilateur, et propose des extensions pour Kotlin ; MyBatis, qui associe des requêtes SQL écrites dans des fichiers XML ou des annotations à des méthodes ;
  • côté Kotlin, Exposed, la bibliothèque d'accès aux bases de données de JetBrains, qui offre les deux styles : un langage de requêtes écrit en Kotlin ([Articles.selectAll().where { Articles.prix greater 200 }]) et une couche objet légère.

Ces outils s'appuient tous sur JDBC : comprendre JDBC reste utile pour comprendre ce qu'ils font et diagnostiquer leurs problèmes. Et, nous allons le voir, quelques fonctions d'extension suffisent déjà à rendre le code JDBC écrit en Kotlin beaucoup moins répétitif.

10.5. Les requêtes paramétrées

10.5.1. Le principe

Une requête paramétrée est un ordre SQL dont certaines valeurs sont remplacées par des paramètres, dont les valeurs sont fournies séparément du texte de la requête. Avec JDBC, un paramètre est noté par un point d'interrogation [?], et l'ordre SQL est un objet [PreparedStatement], créé par [connexion.prepareStatement(requête)]. Les paramètres sont désignés par leur position dans la requête, à partir de 1, et reçoivent leur valeur par les méthodes [setXxx] de l'ordre : [setString(1, "vélo")], [setInt(2, 10)]… Ces méthodes ayant deux paramètres, Kotlin ne les présente pas comme des propriétés : on les appelle comme en Java.

Les programmes qui suivent représentent une ligne de la table [articles] par un objet [Article]. Comme cette classe sert à plusieurs programmes, elle est rangée, avec des fonctions utilitaires, dans le paquetage [commun] du projet. Le fichier [commun/Article.kt] est le suivant :

package commun

import java.math.BigDecimal
import java.sql.ResultSet

// un article : une ligne de la table [articles]
data class Article(
    val id: Int,
    val nom: String,
    val prix: BigDecimal,
    val stockActuel: Int,
    val stockMinimum: Int,
)

// la ligne courante d'un ResultSet [id, nom, prix, stockactuel, stockminimum] devient un Article
fun ResultSet.toArticle() = Article(
    id = getInt("id"),
    nom = getString("nom"),
    prix = getBigDecimal("prix"),
    stockActuel = getInt("stockactuel"),
    stockMinimum = getInt("stockminimum"),
)
  • lignes 7-12 : une classe de données (chapitre 4), l'équivalent Kotlin du [record] de Java : une classe dont le constructeur primaire définit les propriétés, ici toutes en lecture seule ([val]), et dont le compilateur génère les méthodes [equals], [hashCode], [toString], [copy] et les fonctions [componentN] de la déstructuration. Le prix est un [BigDecimal], comme la colonne [DECIMAL] correspondante. La virgule qui suit le dernier paramètre (ligne 12) est permise par Kotlin : elle facilite l'ajout d'un paramètre et la lecture des différences entre deux versions du fichier ;
  • lignes 16-21 : une fonction d'extension (chapitre 4) de l'interface [ResultSet]. Elle s'appelle comme une méthode du résultat, [resultat.toArticle()], et transforme sa ligne courante en un objet [Article]. Dans son corps, [this] est le [ResultSet] : [getInt("id")] est un appel de [this.getInt("id")]. Les colonnes sont désignées par leur nom, et les arguments du constructeur aussi (arguments nommés, chapitre 3) : on voit d'un coup d'œil quelle colonne alimente quelle propriété. La fonction suppose que le résultat a les colonnes [id], [nom], [prix], [stockactuel] et [stockminimum] ; une requête qui ne les fournit pas provoque une [SQLSyntaxErrorException] « Column 'xxx' not found. ». Le type rendu n'est pas écrit : la fonction a un corps d'expression ([= Article(…)]), dont le compilateur infère le type.

L'interface [ResultSet] n'a évidemment pas été écrite pour nos articles : la fonction d'extension permet de lui ajouter, dans notre projet, l'opération qui nous manque, sans dériver de classe ni écrire de classe utilitaire à méthodes statiques comme on le ferait en Java (C# offre la même possibilité avec ses méthodes d'extension). Le fichier [commun/ResultSets.kt] ajoute de la même façon à [ResultSet] deux fonctions qui lisent toutes ses lignes, pour n'importe quel type de ligne :

package commun

import java.sql.ResultSet

// des fonctions d'extension pour lire les lignes d'un ResultSet

// toutes les lignes (restantes) du résultat, chacune transformée par [transformation]
fun <T> ResultSet.toList(transformation: (ResultSet) -> T): List<T> {
    val liste = mutableListOf<T>()
    while (next()) {
        liste.add(transformation(this))
    }
    return liste
}

// les mêmes lignes, lues à la demande : la séquence doit être parcourue avant la fermeture du résultat
fun <T> ResultSet.asSequence(transformation: (ResultSet) -> T): Sequence<T> =
    generateSequence { if (next()) transformation(this) else null }
  • lignes 8-13 : la fonction générique [toList] parcourt les lignes du résultat par la boucle [while (next())] et transforme chacune par la fonction [transformation] reçue en paramètre, de type [(ResultSet) -> T] (chapitre 7). Elle rend la liste des objets obtenus. C'est la boucle que l'on écrit en Java dans chaque méthode qui lit un résultat ; elle est écrite ici une fois pour toutes. Comme le dernier paramètre est une fonction, l'appel s'écrit avec une lambda placée après les parenthèses, qu'on peut omettre : [resultat.toList { it.toArticle() }] rend une [List<Article>] ;
  • lignes 17-18 : la même chose sous la forme d'une séquence (chapitre 5), c'est-à-dire d'une suite d'éléments calculés à la demande. [generateSequence] construit une séquence à partir d'une fonction appelée chaque fois qu'on demande l'élément suivant ; la séquence se termine lorsque cette fonction rend [null]. Ici, la fonction passe à la ligne suivante du résultat et rend sa transformation, ou [null] s'il n'y a plus de ligne. Aucune ligne n'est lue tant qu'on ne parcourt pas la séquence : il faut donc la parcourir avant la fermeture du résultat, c'est-à-dire dans le bloc du [use] qui l'a ouvert. Une séquence ne peut en outre être parcourue qu'une fois, puisque le curseur du [ResultSet] ne revient pas en arrière.

[toList] convient lorsqu'on veut rendre les lignes à l'appelant (la liste survit à la fermeture du résultat) ; [asSequence] lorsqu'on traite les lignes au fur et à mesure, sans les accumuler en mémoire.

Le programme [Jdbc04.kt] met en œuvre les requêtes paramétrées : il vide la table [articles], y insère cinq articles avec une requête paramétrée, puis les relit avec une autre requête paramétrée :

package jdbc04

import commun.Article
import commun.toArticle
import commun.toList
import java.math.BigDecimal
import java.sql.DriverManager
import java.sql.SQLException
import java.sql.Statement

// les requêtes paramétrées

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    println("URL JDBC de la base : [$URL]")

    // création d'une liste de 5 articles
    val articles = (1..5).map { i -> Article(0, "article$i", BigDecimal(i * 100), i * 10, i) }

    try {
        // on supprime les articles existants
        println("Il y a eu ${executeUpdate("delete from articles")} ligne(s) supprimée(s)")
        // on insère la liste des articles
        insertArticles(articles)
        // on affiche les articles de la table
        getArticles("%").forEach(::println)
        // les articles dont le nom se termine par 3
        println("Articles dont le nom se termine par 3 :")
        getArticles("%3").forEach(::println)
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

// insertion d'une liste d'articles avec une requête paramétrée
private fun insertArticles(articles: List<Article>) {
    // la requête paramétrée : chaque ? est un paramètre, numéroté à partir de 1
    val requete = "insert into articles(nom, prix, stockactuel, stockminimum) values(?, ?, ?, ?)"
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.prepareStatement(requete, Statement.RETURN_GENERATED_KEYS).use { ordre ->
            // insertion des lignes : seule la valeur des paramètres change
            for (article in articles) {
                ordre.setString(1, article.nom)
                ordre.setBigDecimal(2, article.prix)
                ordre.setInt(3, article.stockActuel)
                ordre.setInt(4, article.stockMinimum)
                ordre.executeUpdate()
                // la clé primaire générée par le SGBD
                ordre.generatedKeys.use { cles ->
                    cles.next()
                    println("article [${article.nom}] inséré avec l'id ${cles.getInt(1)}")
                }
            }
        }
    }
}

// les articles dont le nom correspond au modèle [like]
private fun getArticles(modele: String): List<Article> {
    val requete = """
        select id, nom, prix, stockactuel, stockminimum from articles
        where nom like ? order by id
    """.trimIndent()
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.prepareStatement(requete).use { ordre ->
            ordre.setString(1, modele)
            // chaque ligne du résultat devient un objet Article
            return ordre.executeQuery().use { resultat -> resultat.toList { it.toArticle() } }
        }
    }
}

// exécution d'un ordre de mise à jour
private fun executeUpdate(requete: String): Int =
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre -> ordre.executeUpdate(requete) }
    }

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc04.Jdbc04Kt
URL JDBC de la base : [jdbc:mysql://localhost:3306/dbarticles]
Il y a eu 6 ligne(s) supprimée(s)
article [article1] inséré avec l'id 7
article [article2] inséré avec l'id 8
article [article3] inséré avec l'id 9
article [article4] inséré avec l'id 10
article [article5] inséré avec l'id 11
Article(id=7, nom=article1, prix=100.00, stockActuel=10, stockMinimum=1)
Article(id=8, nom=article2, prix=200.00, stockActuel=20, stockMinimum=2)
Article(id=9, nom=article3, prix=300.00, stockActuel=30, stockMinimum=3)
Article(id=10, nom=article4, prix=400.00, stockActuel=40, stockMinimum=4)
Article(id=11, nom=article5, prix=500.00, stockActuel=50, stockMinimum=5)
Articles dont le nom se termine par 3 :
Article(id=9, nom=article3, prix=300.00, stockActuel=30, stockMinimum=3)

Commentaires :

  • lignes 3-5 : la classe [Article] et les fonctions d'extension du paquetage [commun] doivent être importées : une fonction d'extension n'est visible que là où elle est importée (ou dans son propre paquetage) ;
  • ligne 21 : une liste de cinq articles, construite par [map] à partir de l'intervalle [1..5] (chapitre 5). L'[id] 0 n'a pas d'importance : il ne sera pas inséré dans la base. [BigDecimal(i * 100)] appelle le constructeur de [BigDecimal] qui reçoit un entier ;
  • ligne 25 : on supprime tous les articles de la table. La fonction [executeUpdate] (ligne 77) rend cette fois le nombre de lignes supprimées. L'appel est placé directement dans le gabarit de la chaîne affichée, entre [${ }] ;
  • ligne 29 : [forEach(::println)] affiche chaque article de la liste, au moyen d'une référence de fonction (chapitre 7). Les résultats montrent la méthode [toString] générée pour la classe de données, de la forme [Article(id=7, nom=article1, …)] (un [record] Java affiche [Article[id=7, …]]) ;
  • ligne 39 : la fonction d'insertion d'une liste d'articles :
    • ligne 41 : la requête d'insertion d'un article. Elle a quatre paramètres, notés [?] : le nom (paramètre n° 1), le prix (n° 2), le stock actuel (n° 3) et le stock minimum (n° 4) ;
    • ligne 43 : [prepareStatement] crée l'ordre paramétré. Le second argument, [Statement.RETURN_GENERATED_KEYS], demande au pilote de récupérer les clés primaires générées par le SGBD lors des insertions ;
    • lignes 45-50 : les articles sont insérés un à un. Chacun des quatre paramètres reçoit sa valeur par la méthode [setXxx] correspondant à son type, puis la requête, maintenant complète, est exécutée par [executeUpdate], sans argument cette fois : le texte de la requête a été donné à la création de l'ordre. Les valeurs des paramètres sont conservées d'une exécution à l'autre jusqu'à ce qu'on les change (ou qu'on appelle [clearParameters]) ;
    • lignes 52-54 : la propriété [generatedKeys] (la méthode Java [getGeneratedKeys]) rend, sous la forme d'un [ResultSet], la clé primaire générée par le SGBD pour la ligne insérée. Ce résultat est lui aussi une ressource, fermée par [use]. Les résultats montrent les [id] 7 à 11 : MySQL ne réutilise pas les valeurs 1 à 6 des lignes supprimées. C'est le moyen de connaître l'identifiant d'une ligne que l'on vient d'insérer ;
  • ligne 62 : la fonction qui rend les articles dont le nom correspond à un modèle [like] de SQL (le caractère [%] y désigne une suite quelconque de caractères : [%3] désigne les noms qui se terminent par 3) :
    • lignes 63-66 : la requête a un paramètre, le modèle. Elle est écrite dans une chaîne brute (chapitre 5), délimitée par trois guillemets, qui peut s'étendre sur plusieurs lignes ; [trimIndent] retire l'indentation commune des lignes ainsi que la première et la dernière ligne, vides. Les requêtes longues se lisent ainsi comme dans un client SQL, sans concaténation. Le saut de ligne conservé entre les deux lignes de la requête est, pour SQL, un séparateur comme un autre ;
    • ligne 69 : le paramètre reçoit sa valeur ;
    • ligne 71 : l'ordre est exécuté, et chaque ligne du résultat devient un objet [Article] grâce aux deux fonctions d'extension : [resultat.toList { it.toArticle() }]. Ces quelques mots remplacent la liste modifiable, la boucle [while] et l'appel du constructeur de la version Java. Le [use] le plus intérieur rend la liste (la valeur de son bloc), et l'instruction [return], placée dans des lambdas de fonctions [inline], termine la fonction [getArticles] : les trois [use] ferment alors, dans l'ordre, le résultat, l'ordre SQL et la connexion. La liste rendue, elle, ne dépend plus d'aucune ressource JDBC ;
  • lignes 77-79 : la fonction [executeUpdate] a un corps d'expression : sa valeur est celle du premier [use], qui est celle du second, qui est le nombre de lignes rendu par [executeUpdate]. On voit là l'intérêt d'un [use] qui rend une valeur.

Les colonnes sont lues par leur nom dans la fonction [toArticle]. Les méthodes [getXxx] acceptent aussi le numéro de la colonne : [getInt(1)], [getString(2)]… L'accès par le nom est plus lisible et résiste mieux à une modification de la requête ; l'accès par le numéro est un peu plus rapide.

Le nom [PreparedStatement] (ordre « préparé ») vient de ce qu'un SGBD peut « préparer » une requête paramétrée, c'est-à-dire la compiler une fois pour toutes. Lorsqu'un SGBD exécute un ordre SQL, il fait un travail d'analyse et d'optimisation avant de l'exécuter (il établit un « plan d'exécution »). Une requête paramétrée est destinée à être exécutée plusieurs fois avec des paramètres différents, mais son texte ne change pas : ce travail peut n'être fait qu'une fois. Avec MySQL Connector/J, la préparation est par défaut simulée par le pilote : il insère lui-même les valeurs des paramètres, correctement protégées, dans le texte de la requête avant de l'envoyer. Le paramètre [useServerPrepStmts=true] de l'URL lui fait utiliser la préparation du serveur MySQL. Dans les deux cas, le code Kotlin est le même.

Les principales correspondances entre les types SQL de MySQL, les méthodes de JDBC et les types Kotlin sont les suivantes :

Type de colonne MySQL
Méthodes JDBC
Type Kotlin
[INT]
[setInt], [getInt]
[Int] ([java.lang.Integer] pour [getObject])
[BIGINT]
[setLong], [getLong]
[Long]
[DECIMAL(p,d)]
[setBigDecimal], [getBigDecimal]
[BigDecimal]
[DOUBLE]
[setDouble], [getDouble]
[Double]
[VARCHAR(n)], chaîne de longueur variable
[setString], [getString]
[String] ([String?] si la colonne accepte [NULL])
[DATETIME]
[setObject(i, valeur)], [getObject(i, LocalDateTime::class.java)]
[LocalDateTime]
[DATE]
[setObject(i, valeur)], [getObject(i, LocalDate::class.java)]
[LocalDate]
[BOOL] (synonyme de [TINYINT(1)])
[setBoolean], [getBoolean]
[Boolean]

Les dates sont représentées par les classes de [java.time] (chapitre 5), que JDBC accepte depuis sa version 4.2 (Java 8) par [setObject] et [getObject]. Les anciennes classes [java.sql.Date], [java.sql.Time] et [java.sql.Timestamp], dérivées de [java.util.Date], et les méthodes [getDate], [getTimestamp] qui les rendent, ne sont plus à utiliser dans du code nouveau. Pour donner la valeur SQL [NULL] à un paramètre, on écrit [setNull(i, Types.INTEGER)], où la classe [java.sql.Types] (ou l'énumération [JDBCType], Java 8) désigne le type SQL du paramètre. Pour lire une colonne entière qui accepte [NULL], on peut écrire en Kotlin [resultat.getInt(i).takeUnless { resultat.wasNull() }], qui rend un [Int?] : la valeur lue, ou [null] si la colonne n'avait pas de valeur.

10.5.2. L'injection SQL

La préparation n'est pas l'unique avantage des requêtes paramétrées, ni le plus important. Considérons la recherche d'un article par son nom. Les gabarits de chaînes de Kotlin rendent très tentante la construction du texte de la requête par programme, en y insérant le nom cherché :

    val requete = "select id, nom, prix from articles where nom = '$nom'"

(c'est l'une des lignes du programme [Jdbc05] ci-dessous). Si le nom vaut « vélo », la requête devient :

select id, nom, prix from articles where nom = 'vélo'

Mais si le nom est « bouteilles d'oxygène », elle devient :

select id, nom, prix from articles where nom = 'bouteilles d'oxygène'

et elle est syntaxiquement incorrecte, à cause de l'apostrophe du nom. Il y a pire : si le nom provient d'une saisie de l'utilisateur, celui-ci peut taper un texte qui modifie le sens de la requête. C'est l'injection SQL, l'une des failles de sécurité les plus répandues et les plus graves des applications qui utilisent une base de données. Le gabarit ["… '$nom'"] n'est rien d'autre qu'une concaténation écrite plus agréablement : il a exactement le même défaut que le ["… '" + nom + "'"] de Java, et sa lisibilité même le rend plus dangereux, car le code paraît propre. Le programme [Jdbc05.kt] met la faille en évidence sur une recherche d'article par son nom :

package jdbc05

import commun.asSequence
import java.sql.DriverManager
import java.sql.ResultSet
import java.sql.SQLException

// l'injection SQL : pourquoi il faut utiliser des requêtes paramétrées

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    // des noms d'articles, tels qu'un utilisateur pourrait les taper
    val noms = listOf("vélo", "bouteilles d'oxygène", "x' or '1'='1")

    println("1 - requête construite avec un gabarit de chaîne")
    noms.forEach(::rechercherAvecGabarit)
    println("\n2 - requête paramétrée")
    noms.forEach(::rechercherAvecParametre)
}

// recherche d'un article par son nom : le nom est inséré dans le texte de la requête (À NE PAS FAIRE)
private fun rechercherAvecGabarit(nom: String) {
    val requete = "select id, nom, prix from articles where nom = '$nom'"
    println("[$nom] -> $requete")
    try {
        DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
            connexion.createStatement().use { ordre ->
                ordre.executeQuery(requete).use(::afficher)
            }
        }
    } catch (ex: SQLException) {
        println("   erreur : ${ex.message}")
    }
}

// recherche d'un article par son nom : le nom est la valeur d'un paramètre
private fun rechercherAvecParametre(nom: String) {
    println("[$nom]")
    try {
        DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
            connexion.prepareStatement("select id, nom, prix from articles where nom = ?").use { ordre ->
                ordre.setString(1, nom)
                ordre.executeQuery().use(::afficher)
            }
        }
    } catch (ex: SQLException) {
        println("   erreur : ${ex.message}")
    }
}

// affichage du résultat d'un select : chaque ligne est affichée dès qu'elle est lue, puis comptée
private fun afficher(resultat: ResultSet) {
    val nbLignes = resultat.asSequence { "${it.getInt(1)} ${it.getString(2)} ${it.getBigDecimal(3)}" }
        .onEach { println("   $it") }
        .count()
    println("   $nbLignes ligne(s)")
}

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc05.Jdbc05Kt
1 - requête construite avec un gabarit de chaîne
[vélo] -> select id, nom, prix from articles where nom = 'vélo'
   1 vélo 500.00
   1 ligne(s)
[bouteilles d'oxygène] -> select id, nom, prix from articles where nom = 'bouteilles d'oxygène'
   erreur : You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'oxygène'' at line 1
[x' or '1'='1] -> select id, nom, prix from articles where nom = 'x' or '1'='1'
   1 vélo 500.00
   2 pompe 10.00
   3 arc 600.00
   4 flèches - lot de 6 100.00
   5 combinaison de plongée 300.00
   6 bouteilles d'oxygène 120.00
   6 ligne(s)

2 - requête paramétrée
[vélo]
   1 vélo 500.00
   1 ligne(s)
[bouteilles d'oxygène]
   6 bouteilles d'oxygène 120.00
   1 ligne(s)
[x' or '1'='1]
   0 ligne(s)

Commentaires :

  • ligne 16 : trois noms tels qu'un utilisateur pourrait les taper : un nom ordinaire, un nom qui contient une apostrophe, et un texte malveillant ;
  • lignes 19 et 21 : chaque nom est passé successivement aux deux fonctions de recherche, désignées par des références de fonctions ;
  • ligne 25 : la première fonction de recherche insère le nom dans le texte de la requête par un gabarit de chaîne (ligne 26), exécuté par un [Statement] ordinaire :
    • ligne 3 des résultats : avec un nom ordinaire, tout se passe bien ;
    • ligne 6 : l'apostrophe du nom termine prématurément la chaîne SQL ; la requête est incorrecte (ligne 7). Un article dont le nom contient une apostrophe ne peut pas être trouvé ;
    • ligne 8 : l'utilisateur a tapé un texte qui contient des apostrophes placées de façon à produire une requête correcte, mais dont la condition [nom = 'x' or '1'='1'] est vraie pour toutes les lignes : la requête rend toute la table (ligne 15). Si la requête vérifiait un nom d'utilisateur et un mot de passe, le même procédé permettrait de se connecter sans connaître le mot de passe. Avec un pilote qui accepte plusieurs ordres SQL dans une même requête, l'utilisateur pourrait même ajouter un ordre de son choix après un point-virgule, par exemple un [delete]. Connector/J le refuse par défaut (paramètre [allowMultiQueries=false]), ce qui limite la portée de l'injection sans la supprimer ;
  • ligne 31 : le résultat est passé à la fonction [afficher] par une référence de fonction : [use(::afficher)] équivaut à [use { afficher(it) }] ;
  • ligne 40 : la seconde fonction utilise une requête paramétrée (lignes 44-45). La valeur du paramètre n'est jamais interprétée comme du SQL : c'est une donnée, quel que soit son contenu. Le nom avec apostrophe est trouvé (ligne 22 des résultats) et le texte malveillant est simplement un nom d'article qui n'existe pas (ligne 25) ;
  • lignes 55-59 : l'affichage du résultat utilise la fonction d'extension [asSequence] du paquetage [commun]. La séquence transforme chaque ligne en chaîne ; [onEach] affiche chaque chaîne au passage et rend la même séquence, que [count] parcourt jusqu'au bout pour compter ses éléments. Comme une séquence est paresseuse, c'est [count] qui provoque la lecture des lignes : chacune est lue, affichée et comptée avant que la suivante soit lue, sans liste intermédiaire. Le parcours a lieu pendant l'appel de [afficher], c'est-à-dire dans le bloc du [use] qui ferme le résultat, comme il se doit.

On pourrait « neutraliser » les apostrophes en les doublant, mais les règles dépendent du SGBD et il est facile d'oublier un cas. La règle est simple : les valeurs fournies par l'utilisateur (ou par toute source extérieure) ne doivent jamais être insérées dans le texte d'une requête SQL, ni par concaténation, ni par un gabarit de chaîne : on utilise toujours un PreparedStatement et des paramètres. Les gabarits restent utiles pour les parties fixes d'une requête choisies par le programme lui-même (un nom de table ou de colonne pris dans une liste connue, par exemple), jamais pour des valeurs. Le programme interactif [Jdbc01.kt] ne respecte pas cette règle, par nature : il exécute les ordres SQL tapés par l'utilisateur. C'est un outil d'administrateur, pas un exemple à suivre.

10.6. Les transactions

10.6.1. Généralités

Une transaction est une suite d'ordres SQL exécutée de façon « atomique » :

  • soit toutes les opérations réussissent ;
  • soit l'une d'elles échoue, et alors toutes celles qui l'ont précédée sont annulées.

Au final, les opérations d'une transaction ont soit toutes été appliquées avec succès, soit aucune ne l'a été. L'exemple classique est le virement bancaire : le débit d'un compte et le crédit d'un autre doivent être faits tous les deux ou pas du tout. Lorsque l'application a elle-même la maîtrise de la transaction, elle la valide par un ordre [COMMIT] ou l'annule par un ordre [ROLLBACK].

Dans nos exemples précédents, nous n'avons pas utilisé de transaction. Et pourtant il y en avait, car dans un SGBD un ordre SQL s'exécute toujours au sein d'une transaction. Si l'application ne démarre pas elle-même une transaction explicite, le SGBD utilise une transaction implicite. Il y a alors deux cas :

  • chaque ordre SQL fait l'objet d'une transaction, ouverte par le SGBD avant l'ordre et validée après. On dit qu'on est en mode autocommit. Tout se passe comme si l'application faisait une transaction pour chaque ordre SQL ;
  • le SGBD n'est pas en mode autocommit : il commence une transaction implicite au premier ordre SQL émis en dehors d'une transaction et laisse l'application la terminer. Tous les ordres SQL émis ensuite font partie de cette transaction implicite, qui peut se terminer sur différents événements (fermeture de la connexion, début d'une nouvelle transaction…), selon des règles propres au SGBD. C'est un mode à éviter.

JDBC impose le premier cas : une connexion JDBC est en mode autocommit lorsqu'elle est ouverte, quel que soit le réglage du SGBD (MySQL, avec le moteur de stockage InnoDB utilisé par défaut, est d'ailleurs lui aussi en mode autocommit).

Les ordres SQL des différents utilisateurs s'exécutent en même temps, dans des transactions qui travaillent en parallèle. Les opérations faites par une transaction peuvent affecter celles faites par une autre. On distingue quatre niveaux d'étanchéité (d'isolation) entre les transactions des différents utilisateurs :

  • Read Uncommitted ;
  • Read Committed ;
  • Repeatable Read ;
  • Serializable.

Read Uncommitted

Ce mode d'isolation est également appelé « Dirty Read ». Voici un exemple de ce qui peut se passer dans ce mode :

  • un utilisateur U1 commence une transaction sur une table T ;
  • un utilisateur U2 commence une transaction sur cette même table T ;
  • l'utilisateur U1 modifie des lignes de la table T mais ne les valide pas encore ;
  • l'utilisateur U2 « voit » ces modifications et prend des décisions à partir de ce qu'il voit ;
  • l'utilisateur U1 annule sa transaction par un [ROLLBACK].

À l'étape 4, l'utilisateur U2 a pris une décision à partir de données qui se révèlent fausses ensuite.

Read Committed

Ce mode d'isolation évite l'écueil précédent : à l'étape 4, l'utilisateur U2 ne « voit » pas les modifications apportées par l'utilisateur U1 à la table T. Il ne les verra qu'après que U1 aura validé sa transaction par un [COMMIT]. Dans ce mode, on peut néanmoins rencontrer la situation suivante :

  • un utilisateur U1 commence une transaction sur une table T ;
  • un utilisateur U2 commence une transaction sur cette même table T ;
  • l'utilisateur U2 fait un [select] pour obtenir la moyenne d'une colonne C des lignes de T vérifiant une certaine condition ;
  • l'utilisateur U1 modifie ([update]) certaines valeurs de la colonne C de T et les valide ([commit]) ;
  • l'utilisateur U2 refait le même [select] qu'à l'étape 3. Il découvre que la moyenne de la colonne C a changé à cause des modifications faites par U1.

L'utilisateur U2 ne voit plus que les modifications validées par U1, mais alors qu'il reste dans la même transaction, deux opérations identiques donnent des résultats différents. On appelle cette situation « Non-Repeatable Read ». Elle est ennuyeuse pour qui désire avoir une image stable de la table T.

Repeatable Read

Dans ce mode d'isolation, un utilisateur est assuré d'obtenir les mêmes résultats pour ses lectures de la base tant qu'il reste dans la même transaction. Il travaille sur une « photographie » des données sur laquelle ne sont pas répercutées les modifications apportées par les autres transactions, même validées. Il ne les verra que lorsqu'il aura lui-même terminé sa transaction.

Ce mode d'isolation n'est pas encore parfait en théorie : une autre transaction peut ajouter ([insert]) des lignes vérifiant la condition de l'étape 3, qui changeraient le résultat d'une nouvelle lecture. On appelle ces lignes des lignes fantômes (« phantom read »). C'est le niveau d'isolation par défaut de MySQL (InnoDB), qui évite d'ailleurs aussi les lignes fantômes pour les lectures simples.

Serializable

Dans ce mode d'isolation, les transactions sont complètement étanches les unes des autres : le résultat de deux transactions menées simultanément est le même que si elles avaient été faites l'une après l'autre. Pour y parvenir, le SGBD verrouille les données lues et fait attendre (ou échouer) les transactions qui voudraient les modifier. Ce niveau diminue le parallélisme et donc les performances.

Les quatre niveaux d'isolation ne sont pas disponibles dans tous les SGBD. Le niveau par défaut est en général Read Committed (SQL Server, PostgreSQL, Oracle) ou Repeatable Read (MySQL). Le niveau désiré peut être indiqué explicitement par l'application.

10.6.2. L'API de gestion des transactions

Avec JDBC, les transactions sont gérées par la connexion. Il n'y a pas d'objet « transaction » : une connexion a au plus une transaction en cours, et tous les ordres SQL exécutés sur cette connexion en font partie. Les méthodes de l'interface [Connection] concernées sont les suivantes. Kotlin voit les couples d'accesseurs Java [getXxx] / [setXxx] comme des propriétés (chapitre 4) : [connexion.setAutoCommit(false)] s'écrit [connexion.autoCommit = false], et [connexion.getAutoCommit()] s'écrit [connexion.autoCommit]. La colonne de gauche donne la forme Kotlin :

Méthode ou propriété
Rôle
[autoCommit]
[autoCommit = false] ([setAutoCommit(false)] en Java) quitte le mode autocommit : les ordres SQL qui suivent font partie d'une transaction, qui commence avec le premier d'entre eux et se termine par [commit] ou [rollback]. Une nouvelle transaction commence alors avec l'ordre suivant. [autoCommit = true] revient au mode autocommit, en validant la transaction en cours.
[commit()]
valide la transaction : les résultats des ordres SQL émis dans la transaction deviennent définitifs et visibles des autres transactions.
[rollback()]
annule la transaction : les résultats des ordres SQL émis dans la transaction sont défaits.
[setSavepoint()]
pose un point de sauvegarde (« savepoint », un objet [Savepoint]) dans la transaction en cours. Ce n'est pas un accesseur (il n'a pas de [getSavepoint] associé) : Kotlin l'appelle comme une fonction.
[rollback(Savepoint)]
annule les ordres SQL exécutés depuis le point de sauvegarde, sans annuler ceux qui le précèdent : la transaction continue.
[transactionIsolation]
le niveau d'isolation des transactions de la connexion ([setTransactionIsolation] en Java). Il doit être fixé avant le début d'une transaction.

Le niveau d'isolation est l'une des constantes de l'interface [Connection] :

Valeur
Signification
[TRANSACTION_READ_UNCOMMITTED]
la transaction peut lire des données écrites par une autre transaction que celle-ci n'a pas encore validées. À éviter.
[TRANSACTION_READ_COMMITTED]
la transaction ne peut pas lire des données écrites par une autre transaction que celle-ci n'a pas encore validées. Les données lues deux fois de suite dans la transaction peuvent cependant changer, car une autre transaction a pu les modifier entre-temps ; une autre transaction a aussi pu ajouter des lignes (lignes fantômes).
[TRANSACTION_REPEATABLE_READ]
les données lues par la transaction ne changent pas tant qu'elle n'est pas terminée. Cela n'évite pas, en théorie, l'ajout de lignes par une autre transaction.
[TRANSACTION_SERIALIZABLE]
tout se passe comme si la transaction était seule. Diminue les performances, car les transactions ne travaillent plus en parallèle.

Ce sont des constantes [static] d'une interface Java : Kotlin les désigne par le nom de l'interface, [Connection.TRANSACTION_READ_COMMITTED], comme les membres d'un [companion object].

Une connexion qui a quitté le mode autocommit doit être fermée après la validation ou l'annulation de sa transaction. Si elle est fermée avec une transaction en cours, le résultat dépend du pilote (MySQL Connector/J l'annule) : il faut toujours terminer explicitement une transaction.

10.6.3. Le programme exemple

Le programme [Jdbc06.kt] montre l'effet d'une transaction :

package jdbc06

import commun.Article
import commun.transaction
import java.math.BigDecimal
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.SQLException

// les transactions

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    println("URL JDBC de la base : [$URL]")

    // deux articles de même nom : la contrainte d'unicité UNQ_NOM refusera le second
    val articles = listOf(
        Article(0, "article", BigDecimal("100"), 10, 1),
        Article(0, "article", BigDecimal("200"), 20, 2),
    )

    try {
        // 1 - insertion sans transaction explicite
        println("\nInsertion sans transaction...")
        executeUpdate("delete from articles")
        try {
            insertArticlesOutOfTransaction(articles)
        } catch (ex: SQLException) {
            println("Erreur d'accès à la base de données (${ex.message})")
        }
        afficherArticles()

        // 2 - la même chose dans une transaction
        println("\nInsertion dans une transaction...")
        executeUpdate("delete from articles")
        insertArticlesInTransaction(articles)
        afficherArticles()

        // 3 - une transaction avec un point de sauvegarde
        println("\nInsertion dans une transaction avec un point de sauvegarde...")
        executeUpdate("delete from articles")
        insertArticlesWithSavepoint(articles)
        afficherArticles()

        // 4 - une transaction gérée par la fonction d'ordre supérieur [transaction]
        println("\nInsertion dans une transaction gérée par la fonction [transaction]...")
        executeUpdate("delete from articles")
        try {
            insertArticlesWithFunction(articles)
        } catch (ex: SQLException) {
            println("transaction invalidée (${ex.message})")
        }
        insertArticlesWithFunction(articles.take(1))
        afficherArticles()
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

// insertion d'une liste d'articles sans transaction explicite (mode autocommit)
private fun insertArticlesOutOfTransaction(articles: List<Article>) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        creerOrdreInsert(connexion).use { ordre ->
            articles.forEach { insererArticle(ordre, it) }
        }
    }
}

// insertion d'une liste d'articles dans une transaction
private fun insertArticlesInTransaction(articles: List<Article>) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        // début de la transaction : on quitte le mode autocommit
        connexion.transactionIsolation = Connection.TRANSACTION_READ_COMMITTED
        connexion.autoCommit = false
        try {
            creerOrdreInsert(connexion).use { ordre ->
                articles.forEach { insererArticle(ordre, it) }
            }
            // tout s'est bien passé : on valide la transaction
            connexion.commit()
            println("transaction validée...")
        } catch (ex: SQLException) {
            // une insertion a échoué : on défait la transaction
            connexion.rollback()
            println("transaction invalidée (${ex.message})")
        }
    }
}

// insertion dans une transaction : l'échec d'une insertion n'annule que ce qui suit
// le dernier point de sauvegarde
private fun insertArticlesWithSavepoint(articles: List<Article>) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.autoCommit = false
        try {
            creerOrdreInsert(connexion).use { ordre ->
                for (article in articles) {
                    // un point de sauvegarde avant chaque insertion
                    val avantInsertion = connexion.setSavepoint()
                    try {
                        insererArticle(ordre, article)
                    } catch (ex: SQLException) {
                        // seule l'insertion ratée est défaite
                        connexion.rollback(avantInsertion)
                        println("insertion de [${article.nom}, ${article.prix}] annulée (${ex.message})")
                    }
                }
            }
            connexion.commit()
            println("transaction validée...")
        } catch (ex: SQLException) {
            connexion.rollback()
            println("transaction invalidée (${ex.message})")
        }
    }
}

// insertion dans une transaction gérée par la fonction [transaction] : les exceptions sont propagées
private fun insertArticlesWithFunction(articles: List<Article>) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        val nbArticles = connexion.transaction(Connection.TRANSACTION_READ_COMMITTED) {
            // dans le bloc, [this] est la connexion
            creerOrdreInsert(this).use { ordre ->
                articles.forEach { insererArticle(ordre, it) }
            }
            articles.size
        }
        println("transaction validée : $nbArticles article(s) inséré(s)")
    }
}

// l'ordre paramétré d'insertion d'un article
private fun creerOrdreInsert(connexion: Connection): PreparedStatement = connexion.prepareStatement(
    "insert into articles(nom, prix, stockactuel, stockminimum) values(?, ?, ?, ?)"
)

// insertion d'un article avec l'ordre paramétré
private fun insererArticle(ordre: PreparedStatement, article: Article) {
    with(article) {
        ordre.setString(1, nom)
        ordre.setBigDecimal(2, prix)
        ordre.setInt(3, stockActuel)
        ordre.setInt(4, stockMinimum)
        ordre.executeUpdate()
        println("article [$nom, $prix] inséré")
    }
}

// exécution d'un ordre de mise à jour
private fun executeUpdate(requete: String) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre ->
            println("Il y a eu ${ordre.executeUpdate(requete)} ligne(s) modifiée(s)")
        }
    }
}

// affichage du contenu de la table
private fun afficherArticles() {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre ->
            val requete = "select id, nom, prix, stockactuel, stockminimum from articles"
            ordre.executeQuery(requete).use { resultat ->
                println("Contenu de la table [articles] :")
                while (resultat.next()) {
                    // dans le bloc de with, [this] est le résultat : getInt(1) est resultat.getInt(1)
                    val ligne = with(resultat) {
                        "${getInt(1)} ${getString(2)} ${getBigDecimal(3)} ${getInt(4)} ${getInt(5)}"
                    }
                    println("  $ligne")
                }
            }
        }
    }
}

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc06.Jdbc06Kt
URL JDBC de la base : [jdbc:mysql://localhost:3306/dbarticles]

Insertion sans transaction...
Il y a eu 6 ligne(s) modifiée(s)
article [article, 100] inséré
Erreur d'accès à la base de données (Duplicate entry 'article' for key 'articles.UNQ_NOM')
Contenu de la table [articles] :
  7 article 100.00 10 1

Insertion dans une transaction...
Il y a eu 1 ligne(s) modifiée(s)
article [article, 100] inséré
transaction invalidée (Duplicate entry 'article' for key 'articles.UNQ_NOM')
Contenu de la table [articles] :

Insertion dans une transaction avec un point de sauvegarde...
Il y a eu 0 ligne(s) modifiée(s)
article [article, 100] inséré
insertion de [article, 200] annulée (Duplicate entry 'article' for key 'articles.UNQ_NOM')
transaction validée...
Contenu de la table [articles] :
  11 article 100.00 10 1

Insertion dans une transaction gérée par la fonction [transaction]...
Il y a eu 1 ligne(s) modifiée(s)
article [article, 100] inséré
transaction invalidée (Duplicate entry 'article' for key 'articles.UNQ_NOM')
article [article, 100] inséré
transaction validée : 1 article(s) inséré(s)
Contenu de la table [articles] :
  15 article 100.00 10 1

Commentaires :

  • lignes 21-23 : une liste de deux articles de même nom, [article] ([Article] est la classe de données du paquetage [commun]). La contrainte d'unicité [UNQ_NOM] de la table [articles] empêche qu'ils soient présents en même temps dans la base : l'insertion du second va provoquer une [SQLException]. Pour montrer l'effet d'une transaction, les deux articles vont être insérés de quatre façons :
    • d'abord en dehors de toute transaction explicite. La connexion étant en mode autocommit, chaque insertion est faite dans sa propre transaction. Le premier article va être inséré, le second ne le sera pas ;
    • ensuite dans une transaction englobant les deux insertions. Parce que la seconde insertion va échouer, la première sera défaite : au final, aucune insertion ne sera faite ;
    • puis dans une transaction avec un point de sauvegarde avant chaque insertion : l'échec de la seconde insertion n'annulera qu'elle-même, et la transaction sera validée avec la première ;
    • enfin dans une transaction gérée par une fonction d'ordre supérieur, [transaction], que nous présentons au paragraphe suivant ;
  • lignes 27-35 : la table est vidée, puis on insère les deux articles sans transaction explicite. Comme on sait que la seconde insertion va lancer une exception, celle-ci est interceptée par un [try] / [catch] ; enfin la table est affichée ;
  • lignes 37-40 : on refait la même séquence, mais avec une transaction. L'exception est cette fois gérée dans la fonction [insertArticlesInTransaction] ;
  • lignes 43-46 : de même avec des points de sauvegarde ;
  • ligne 65 : la fonction d'insertion sans transaction. Elle utilise deux fonctions, [creerOrdreInsert] (ligne 137), qui crée l'ordre paramétré d'insertion, et [insererArticle] (ligne 142), qui donne leur valeur aux paramètres et exécute l'ordre. Les deux [use] imbriqués ferment l'ordre puis la connexion, comme le faisait le try-with-resources de Java ;
  • ligne 74 : la fonction d'insertion dans une transaction :
    • ligne 77 : le niveau d'isolation [TRANSACTION_READ_COMMITTED], fixé avant le début de la transaction, par une affectation de propriété ;
    • ligne 78 : la connexion quitte le mode autocommit : la transaction commencera avec la première insertion ;
    • lignes 80-81 : les insertions ;
    • ligne 84 : tout s'est bien passé : la transaction est validée, les insertions sont définitivement intégrées à la base ;
    • lignes 86-89 : une insertion a échoué : la transaction est annulée ;
  • ligne 96 : la fonction d'insertion avec des points de sauvegarde :
    • ligne 103 : un point de sauvegarde est posé avant chaque insertion. [setSavepoint] n'est pas l'accesseur d'une propriété (elle n'a pas de paramètre) : on l'appelle comme une fonction ;
    • ligne 108 : si l'insertion échoue, on revient au point de sauvegarde : seule l'insertion ratée est défaite, et la boucle continue ;
    • ligne 113 : la transaction est validée, avec les insertions qui ont réussi ;
  • lignes 142-149 : la fonction [with] de la bibliothèque standard exécute son bloc avec l'article pour receveur : dans le bloc, [nom] désigne [article.nom]. La fonction [afficherArticles] l'utilise de la même façon avec le résultat de la requête (ligne 171), ce qui évite de répéter cinq fois [resultat.] ;
  • ligne 154 : comme toutes les fonctions du programme, [executeUpdate] ne déclare aucune exception : Kotlin n'a pas d'exceptions contrôlées (chapitre 3), et la clause [throws SQLException] que Java imposait à chaque méthode disparaît. Les [SQLException] remontent jusqu'au [try] de la fonction [main] (ligne 59).

Dans les résultats :

  • ligne 5 : affichée par l'ordre [delete] : il y avait six lignes dans la table ;
  • ligne 7 : l'exception provoquée par la seconde insertion. Le message indique que la contrainte [UNQ_NOM] de la table [articles] n'a pas été respectée ;
  • ligne 9 : la table n'est pas vide : le premier article a été inséré ;
  • ligne 12 : affichée par l'ordre [delete] : il y avait une ligne dans la table ;
  • ligne 13 : la première insertion a réussi… dans la transaction ;
  • ligne 14 : le message affiché lorsque la transaction échoue ;
  • ligne 15 : la table est vide : le [rollback] de la transaction a défait la première insertion ;
  • lignes 19-21 : avec les points de sauvegarde, la seconde insertion a été annulée et la transaction validée ;
  • ligne 23 : la table contient le premier article. Son [id] est 11 et non 8 : chaque tentative d'insertion, même annulée ou refusée, a consommé une valeur de la colonne [AUTO_INCREMENT], et MySQL ne réutilise pas ces valeurs.

Avec MySQL, l'échec d'un ordre SQL n'annule que cet ordre, et la transaction peut continuer : le point de sauvegarde n'est donc pas indispensable ici. Ce n'est pas le cas de tous les SGBD : avec PostgreSQL, la moindre erreur rend la transaction inutilisable jusqu'à son annulation, ou jusqu'au retour à un point de sauvegarde. Le code avec points de sauvegarde fonctionne avec les deux.

10.6.4. Une fonction d'ordre supérieur : transaction { }

Les fonctions [insertArticlesInTransaction] et [insertArticlesWithSavepoint] répètent le même schéma : quitter le mode autocommit, faire le travail, valider, annuler en cas d'exception. Ce schéma est toujours le même, seul le travail change : c'est le cas d'usage type d'une fonction d'ordre supérieur (chapitre 7), qui reçoit le travail sous la forme d'une lambda, exactement comme [use] reçoit le travail à faire avant la fermeture d'une ressource. Nous l'écrivons une fois pour toutes, sous la forme d'une fonction d'extension de [Connection], dans le fichier [Transactions.kt] du paquetage [commun] :

package commun

import java.sql.Connection
import java.sql.SQLException

// exécute [bloc] dans une transaction de la connexion :
// - validée (commit) si le bloc se termine normalement ;
// - annulée (rollback) s'il lance une exception, qui est ensuite relancée ;
// le mode autocommit de la connexion est rétabli à la fin
// [crossinline] : le bloc ne peut pas contenir de return qui quitterait la fonction appelante
inline fun <T> Connection.transaction(isolation: Int? = null, crossinline bloc: Connection.() -> T): T {
    // le niveau d'isolation éventuel est fixé avant le début de la transaction
    isolation?.let { transactionIsolation = it }
    val autoCommitInitial = autoCommit
    autoCommit = false
    try {
        val resultat = bloc()
        commit()
        return resultat
    } catch (e: Throwable) {
        try {
            rollback()
        } catch (ex: SQLException) {
            // l'échec du rollback ne doit pas masquer l'exception d'origine
            e.addSuppressed(ex)
        }
        throw e
    } finally {
        autoCommit = autoCommitInitial
    }
}
  • ligne 11 : la fonction [transaction] est une fonction d'extension de l'interface [Connection] : on l'appelle comme une méthode de la connexion, [connexion.transaction { … }]. Elle a deux paramètres :
    • [isolation], le niveau d'isolation, de type [Int?] et de valeur par défaut [null] (le niveau de la connexion est alors inchangé) ;
    • [bloc], le travail à faire dans la transaction. Son type, [Connection.() -> T], est celui d'une lambda avec receveur : dans le bloc, [this] est la connexion, et l'on peut écrire [prepareStatement(…)] au lieu de [connexion.prepareStatement(…)]. C'est le mécanisme qu'utilisent [with], [apply] ou [buildList] de la bibliothèque standard. Le bloc rend une valeur de type [T], que [transaction] rend à son tour. Le bloc étant le dernier paramètre, il s'écrit après les parenthèses de l'appel, ou sans parenthèses du tout s'il est le seul argument ;
  • [inline] : le code de la fonction et celui du bloc sont recopiés à l'endroit de l'appel (chapitre 7) : aucun objet lambda n'est créé à chaque transaction. Mais une lambda passée à une fonction [inline] peut contenir un [return] qui quitte la fonction appelante (un retour « non local », comme dans le corps d'une boucle). Ici, ce serait un piège : un [return] au milieu du bloc sortirait du [try] sans passer ni par [commit] ni par [rollback], et la transaction resterait en suspens. Le modificateur [crossinline] du paramètre [bloc] l'interdit. Le compilateur refuse alors un bloc comme [connexion.transaction { if (nom.isEmpty()) return … }] :
1
2
3
Essai.kt:8:28: error: 'return' is prohibited here.
        if (nom.isEmpty()) return
                           ^^^^^^

Le bloc se termine donc toujours par la valeur qu'il rend (un [return@transaction valeur] reste permis : il ne quitte que la lambda) ;

  • ligne 13 : si un niveau d'isolation est donné, il est fixé avant le début de la transaction ;
  • lignes 14-15 : on mémorise le mode de la connexion, puis on quitte le mode autocommit ;
  • lignes 17-19 : le bloc est exécuté, puis la transaction est validée et le résultat du bloc rendu ;
  • lignes 20-27 : si le bloc lance une exception, quelle qu'elle soit, la transaction est annulée et l'exception relancée : l'appelant la reçoit comme si la fonction [transaction] n'existait pas. Si le [rollback] échoue lui-même (connexion perdue), son exception ne doit pas masquer celle d'origine, plus intéressante : elle lui est attachée comme exception supprimée ([addSuppressed]), comme le fait le try-with-resources de Java ou la fonction [use] de Kotlin pour les exceptions de [close] ;
  • lignes 28-29 : le mode autocommit est rétabli dans tous les cas. C'est utile avec un pool de connexions : la connexion rendue au pool retrouve son état initial.

Le programme [Jdbc06] utilise cette fonction dans sa quatrième partie :

        // 4 - une transaction gérée par la fonction d'ordre supérieur [transaction]
        println("\nInsertion dans une transaction gérée par la fonction [transaction]...")
        executeUpdate("delete from articles")
        try {
            insertArticlesWithFunction(articles)
        } catch (ex: SQLException) {
            println("transaction invalidée (${ex.message})")
        }
        insertArticlesWithFunction(articles.take(1))
        afficherArticles()
// insertion dans une transaction gérée par la fonction [transaction] : les exceptions sont propagées
private fun insertArticlesWithFunction(articles: List<Article>) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        val nbArticles = connexion.transaction(Connection.TRANSACTION_READ_COMMITTED) {
            // dans le bloc, [this] est la connexion
            creerOrdreInsert(this).use { ordre ->
                articles.forEach { insererArticle(ordre, it) }
            }
            articles.size
        }
        println("transaction validée : $nbArticles article(s) inséré(s)")
    }
}
  • lignes 2-11 : le code de la transaction se réduit au travail à faire. Le bloc reçoit la connexion pour receveur ([creerOrdreInsert(this)]), et rend le nombre d'articles insérés, qui devient la valeur de [connexion.transaction(…)]. Aucune exception n'est interceptée : en cas d'échec, la transaction est annulée et l'exception transmise à l'appelant ;
  • dans la partie 4 de [main] (premier listing ci-dessus), la seconde insertion de la liste de deux articles échoue : la fonction [transaction] annule la transaction et relance l'exception, que l'appelant intercepte ; il affiche « transaction invalidée » (ligne 28 des résultats) ; la table est restée vide, puisque l'insertion de la ligne 27 a été défaite. L'insertion d'une liste d'un seul article, [articles.take(1)], réussit (lignes 29-30) : la table contient l'article (ligne 32).

La fonction [transaction] est dix lignes de code, mais elle exprime en Kotlin ce que proposent les cadriciels : Spring offre [TransactionTemplate] et l'annotation [@Transactional] (chapitre 11), et la bibliothèque Exposed de JetBrains, présentée en conclusion, a justement une fonction [transaction { … }]. Nous la réutiliserons dans les programmes [Jdbc08] et [Jdbc09].

10.7. Les requêtes qui rendent une valeur unique

Certaines requêtes ne rendent qu'une valeur, comme [select count(*) from articles] : une ligne d'une colonne. JDBC n'a pas de méthode particulière pour elles : on exécute la requête par [executeQuery] et on lit la première colonne de la première ligne du résultat. Le programme [Jdbc07.kt] écrit deux fonctions pour cela et en montre quelques utilisations :

package jdbc07

import java.math.BigDecimal
import java.math.RoundingMode
import java.sql.DriverManager

// les requêtes qui rendent une valeur unique

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    try {
        // les valeurs uniques rendues par des select, dans le type choisi par le pilote
        val prixMoyen = valeurUnique("select avg(prix) from articles")
        println("Prix moyen des articles = $prixMoyen (type ${type(prixMoyen)})")
        val nbArticles = valeurUnique("select count(id) from articles")
        println("Nombre d'articles = $nbArticles (type ${type(nbArticles)})")
        val nom = valeurUnique("select nom from articles where prix = (select max(prix) from articles)")
        println("Article le plus cher = $nom (type ${type(nom)})")
        val absent = valeurUnique("select nom from articles where prix > 10000")
        println("Article de plus de 10000 euros : $absent")
        val vide = valeurUnique("select max(prix) from articles where prix > 10000")
        println("Prix maximal au-delà de 10000 euros : $vide")

        // les mêmes valeurs dans le type demandé : c'est le pilote qui fait la conversion
        val moyenne = valeurUniqueTypee<BigDecimal>("select avg(prix) from articles")
        val nombre = valeurUniqueTypee<Int>("select count(id) from articles")
        println("Moyenne arrondie = ${moyenne?.setScale(2, RoundingMode.HALF_UP)}, nombre = $nombre")
        // une valeur absente : l'opérateur Elvis fournit une valeur par défaut
        val maximum = valeurUniqueTypee<BigDecimal>("select max(prix) from articles where prix > 10000")
            ?: BigDecimal.ZERO
        println("Prix maximal au-delà de 10000 euros, ou 0 : $maximum")

        // le transtypage sûr as? rend null si l'objet n'est pas du type demandé
        println("nbArticles as? Int = ${nbArticles as? Int}, nbArticles as? Long = ${nbArticles as? Long}")
        // le transtypage intelligent après un test de type
        if (nbArticles is Number) {
            println("nbArticles.toInt() = ${nbArticles.toInt()}")
        }
        // un transtypage direct échoue : count rend un Long et non un Int
        val n = nbArticles as Int
        println("n = $n")
    } catch (ex: Exception) {
        // SQLException ou ClassCastException
        println("${ex::class.simpleName} : ${ex.message}")
    }
}

// exécution d'un select qui rend une seule valeur : première colonne de la première ligne
// le résultat est null si le select ne rend aucune ligne ou si la valeur est SQL NULL
private fun valeurUnique(requete: String): Any? =
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre ->
            ordre.executeQuery(requete).use { resultat ->
                if (resultat.next()) resultat.getObject(1) else null
            }
        }
    }

// la même chose, la valeur étant convertie par le pilote dans le type T demandé
// [reified] : le type T est connu à l'exécution, T::class.java est la classe Java de T
private inline fun <reified T : Any> valeurUniqueTypee(requete: String): T? =
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre ->
            ordre.executeQuery(requete).use { resultat ->
                if (resultat.next()) resultat.getObject(1, T::class.java) else null
            }
        }
    }

// le nom simple de la classe d'un objet
private fun type(objet: Any?): String = objet?.let { it::class.simpleName } ?: "null"

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc07.Jdbc07Kt
Prix moyen des articles = 271.666667 (type BigDecimal)
Nombre d'articles = 6 (type Long)
Article le plus cher = arc (type String)
Article de plus de 10000 euros : null
Prix maximal au-delà de 10000 euros : null
Moyenne arrondie = 271.67, nombre = 6
Prix maximal au-delà de 10000 euros, ou 0 : 0
nbArticles as? Int = null, nbArticles as? Long = 6
nbArticles.toInt() = 6
ClassCastException : class java.lang.Long cannot be cast to class java.lang.Integer (java.lang.Long and java.lang.Integer are in module java.base of loader 'bootstrap')

Commentaires :

  • ligne 53 : la fonction [valeurUnique] exécute la requête et rend la première colonne de la première ligne par [getObject(1)], ou [null] si la requête ne rend aucune ligne (ligne 57). Son type de retour est [Any?] : n'importe quel objet, ou [null]. Le corps de la fonction est une expression : les trois [use] imbriqués rendent la valeur de leur bloc, et la valeur du [if] devient celle de la fonction ;
  • ligne 16 : le prix moyen des articles. Le type du résultat dépend du type SQL de la valeur : la moyenne d'une colonne [DECIMAL] est un [BigDecimal], ici de six décimales (ligne 2 des résultats). La fonction [type] (ligne 74) rend le nom simple de la classe de l'objet, [it::class.simpleName], ou « null » ;
  • ligne 18 : le nombre d'articles. MySQL rend un entier de type [BIGINT], qui devient un [java.lang.Long], que Kotlin nomme [Long] (ligne 3) ;
  • ligne 20 : le nom de l'article le plus cher, une chaîne de caractères. La requête contient une sous-requête, entre parenthèses ;
  • ligne 22 : si la requête ne rend aucune ligne, la fonction rend [null] (ligne 5) ;
  • ligne 24 : si la requête rend une ligne dont la valeur est SQL [NULL] (le maximum d'un ensemble vide), [getObject] rend aussi [null] (ligne 6). Notre fonction ne distingue donc pas les deux cas, pas de ligne ou une valeur [NULL] ; si la distinction importe, on teste soi-même le résultat de [next] ;
  • ligne 64 : une seconde fonction, générique, rend une valeur du type [T] demandé, ou [null]. En Java, la méthode équivalente devait recevoir le type sous la forme d'un paramètre [Class<T> type], car les types génériques sont effacés à la compilation : à l'exécution, la méthode ne sait pas ce qu'est [T]. Une fonction [inline] dont le paramètre de type est [reified] (« réifié », chapitre 4) n'a pas cette limite : son code est recopié à chaque appel avec le vrai type, et [T::class.java] (ligne 68) est la classe Java de ce type, que [getObject(1, type)] utilise pour demander au pilote de convertir la valeur. L'appel s'écrit simplement [valeurUniqueTypee<BigDecimal>(…)] ;
  • lignes 28-30 : le pilote convertit lui-même la valeur dans le type demandé : la moyenne en [BigDecimal], le nombre d'articles ([BIGINT]) en [Int]. Le résultat étant de type [BigDecimal?], l'appel de [setScale], qui arrondit la moyenne à deux décimales, passe par l'opérateur [?.] (ligne 7) ;
  • lignes 32-33 : l'opérateur Elvis [?:] remplace l'absence de valeur par une valeur par défaut : [maximum] est de type [BigDecimal], non nullable (ligne 8) ;
  • ligne 37 : le transtypage sûr [as?] rend [null] au lieu de lancer une exception lorsque l'objet n'est pas du type demandé : [nbArticles] n'est pas un [Int], mais c'est un [Long] (ligne 9) ;
  • lignes 39-40 : après le test [is Number], [nbArticles] est automatiquement transtypé en [Number] dans le bloc du [if] (transtypage intelligent, chapitre 4), et l'on peut appeler [toInt]. C'est la façon robuste de traiter une valeur numérique dont on ne connaît pas le type exact (ligne 10) ;
  • ligne 43 : un transtypage direct échoue, car l'objet est un [java.lang.Long] : sur la JVM, un [Int] rangé dans une variable de type [Any?] est un [java.lang.Integer], et un [Long] n'en est pas un. Une [ClassCastException] est lancée (ligne 11) : son message parle des classes Java [java.lang.Long] et [java.lang.Integer]. Ce code fonctionnerait avec un SGBD dont le [count] rend un entier de type [INT] et échoue avec MySQL : c'est un exemple des particularités des SGBD que JDBC ne cache pas. La lecture par [getInt], [getLong] ou par la fonction [valeurUniqueTypee], qui laisse la conversion au pilote, est plus sûre ;
  • ligne 45 : Kotlin n'a pas de clause [catch] multiple comme le [catch (SQLException | ClassCastException ex)] de Java : on intercepte ici leur classe commune, [Exception]. Pour traiter différemment plusieurs types, on écrit plusieurs clauses [catch], ou une seule suivie d'un [when (ex)] avec des branches [is SQLException ->].

Pourquoi deux noms, [valeurUnique] et [valeurUniqueTypee], et non deux surcharges de [valeurUnique] comme en Java ? Parce que les deux fonctions auraient la même signature pour la JVM : le paramètre de type [T] étant effacé, la fonction générique devient, comme l'autre, une fonction qui reçoit un [String] et rend un [Object]. Le compilateur refuse les deux déclarations :

1
2
3
4
5
Jdbc07.kt:53:9: error: platform declaration clash: The following declarations have the same JVM signature (valeurUnique(Ljava/lang/String;)Ljava/lang/Object;):
    fun valeurUnique(requete: String): Any? defined in jdbc07
    inline fun <reified T : Any> valeurUnique(requete: String): T? defined in jdbc07
private fun valeurUnique(requete: String): Any? =
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

En Java, les deux méthodes se distinguaient par le paramètre [Class<T>] de la seconde, que le paramètre réifié rend inutile en Kotlin. (L'annotation [@JvmName("…")] permettrait de donner un autre nom JVM à l'une d'elles, mais un nom différent est ici plus clair.)

10.8. JDBC est bloquant : threads virtuels et coroutines

10.8.1. Les threads virtuels

Le chapitre 9 a montré que les opérations d'entrées / sorties ne doivent pas immobiliser un thread de l'application dans les applications qui servent de nombreux utilisateurs (serveurs, services web) : pendant qu'un ordre SQL s'exécute sur le serveur, le thread qui l'a émis attend. Or JDBC est une API bloquante : [getConnection], [executeQuery], [next], [commit]… ne rendent la main que lorsque le SGBD a répondu. Il n'existe pas de version asynchrone de JDBC : un projet d'API asynchrone (ADBA) a été abandonné en 2019, et l'API réactive R2DBC, qui existe en dehors du JDK, n'a pas remplacé JDBC. Il n'existe pas davantage de version « suspendue » de JDBC pour les coroutines : une fonction JDBC appelée dans une coroutine bloque le thread qui exécute la coroutine.

Depuis Java 21, la réponse de la plateforme Java est le thread virtuel (chapitre 8) : un thread léger, géré par la JVM, dont on peut créer des milliers. Lorsqu'un thread virtuel attend une réponse du réseau, la JVM le met de côté et libère le thread du système qui l'exécutait, qui peut alors exécuter un autre thread virtuel. Le code reste séquentiel et bloquant, simple à écrire et à lire, mais l'attente ne coûte plus rien. Les pilotes JDBC récents, dont Connector/J 9, sont écrits pour fonctionner avec les threads virtuels, et depuis Java 24 (JEP 491) un thread virtuel qui attend dans un bloc [synchronized] ne monopolise plus le thread du système. Kotlin, qui s'exécute sur la JVM, en profite sans rien faire de particulier.

Il reste une limite : le nombre de connexions. Mille threads virtuels qui interrogent la base n'ont pas mille connexions : ils se partagent celles du pool, et attendent qu'une connexion se libère. Le programme [Jdbc08.kt] le montre, avec un pool HikariCP de quatre connexions :

package jdbc08

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import commun.Article
import commun.toArticle
import commun.transaction
import java.sql.SQLException
import java.sql.SQLTimeoutException
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import javax.sql.DataSource
import kotlin.time.DurationUnit
import kotlin.time.TimeSource
import kotlin.time.measureTime

// JDBC est bloquant : des requêtes concurrentes exécutées par des threads virtuels

fun main() {
    // la source de données : un pool d'au plus 4 connexions vers la base [dbarticles]
    val config = HikariConfig().apply {
        jdbcUrl = "jdbc:mysql://localhost:3306/dbarticles"
        username = "root"
        password = ""
        maximumPoolSize = 4
    }

    try {
        HikariDataSource(config).use { dataSource ->
            // 1 - un ordre de mise à jour, dans une transaction
            dataSource.connection.use { connexion ->
                val nbLignes = connexion.transaction {
                    prepareStatement("update articles set prix = prix * 1.1 where prix < ?").use { ordre ->
                        ordre.setInt(1, 200)
                        ordre.executeUpdate()
                    }
                }
                println("$nbLignes prix augmenté(s) de 10 %")
            }

            // 2 - six requêtes concurrentes, chacune dans son thread virtuel
            val duree = measureTime {
                val taches = Executors.newVirtualThreadPerTaskExecutor().use { executor ->
                    (1..6).map { id -> executor.submit(Callable { getArticleLentement(dataSource, id) }) }
                } // la fermeture de l'executor attend la fin des six tâches
                taches.forEach { println(it.get()) }
            }
            println("6 requêtes d'une seconde exécutées en %.1f s avec %d connexions"
                .format(duree.toDouble(DurationUnit.SECONDS), config.maximumPoolSize))

            // 3 - une valeur unique
            dataSource.connection.use { connexion ->
                connexion.createStatement().use { ordre ->
                    ordre.executeQuery("select sum(prix * stockactuel) from articles").use { resultat ->
                        resultat.next()
                        println("Valeur du stock : ${resultat.getBigDecimal(1)} euros")
                    }
                }
            }

            // 4 - une requête bloquée par une autre transaction, abandonnée au bout d'une seconde
            // la transaction [bloquante] modifie l'article n° 1 et ne se termine pas :
            // la ligne reste verrouillée
            dataSource.connection.use { bloquante ->
                bloquante.autoCommit = false
                bloquante.createStatement().use { ordre ->
                    ordre.executeUpdate("update articles set stockactuel = stockactuel - 1 where id = 1")
                }
                // une autre connexion veut modifier la même ligne : elle doit attendre la fin de [bloquante]
                val chrono = TimeSource.Monotonic.markNow()
                try {
                    dataSource.connection.use { connexion ->
                        connexion.createStatement().use { ordre ->
                            ordre.queryTimeout = 1
                            ordre.executeUpdate("update articles set prix = prix * 2 where id = 1")
                        }
                    }
                } catch (ex: SQLTimeoutException) {
                    val secondes = chrono.elapsedNow().inWholeSeconds
                    println("${ex::class.simpleName} au bout de $secondes s : ${ex.message}")
                }
                // la transaction [bloquante] n'a pas été validée : on la défait
                bloquante.rollback()
            }
        }
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    } catch (ex: ExecutionException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

// un article lu par une requête qui dure une seconde (la fonction MySQL sleep simule une requête lente)
private fun getArticleLentement(dataSource: DataSource, id: Int): Article =
    dataSource.connection.use { connexion ->
        connexion.prepareStatement(
            "select id, nom, prix, stockactuel, stockminimum, sleep(1) from articles where id = ?"
        ).use { ordre ->
            ordre.setInt(1, id)
            ordre.executeQuery().use { resultat ->
                resultat.next()
                resultat.toArticle()
            }
        }
    }

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc08.Jdbc08Kt
3 prix augmenté(s) de 10 %
Article(id=1, nom=vélo, prix=500.00, stockActuel=10, stockMinimum=5)
Article(id=2, nom=pompe, prix=11.00, stockActuel=10, stockMinimum=2)
Article(id=3, nom=arc, prix=600.00, stockActuel=4, stockMinimum=1)
Article(id=4, nom=flèches - lot de 6, prix=110.00, stockActuel=12, stockMinimum=20)
Article(id=5, nom=combinaison de plongée, prix=300.00, stockActuel=8, stockMinimum=2)
Article(id=6, nom=bouteilles d'oxygène, prix=132.00, stockActuel=10, stockMinimum=5)
6 requêtes d'une seconde exécutées en 2,1 s avec 4 connexions
Valeur du stock : 12550.00 euros
MySQLTimeoutException au bout de 1 s : Statement cancelled due to timeout or client request

Commentaires :

  • lignes 22-26 : la configuration d'un pool d'au plus quatre connexions. Dans le bloc de [apply], [this] est l'objet [HikariConfig] : les accesseurs [setJdbcUrl], [setUsername]… deviennent des affectations de propriétés ;
  • ligne 30 : la source de données [HikariDataSource], fermée à la fin du programme par [use] ;
  • lignes 31-39 : un ordre de mise à jour dans une transaction, sur une connexion obtenue du pool ([dataSource.connection], la propriété qui correspond à [getConnection()]). La transaction est gérée par la fonction [transaction] du paquetage [commun] : le bloc rend le nombre de lignes modifiées, qui devient la valeur de [nbLignes]. La fermeture de la connexion par [use] la rend au pool. Les résultats montrent que les trois prix inférieurs à 200 euros ont été augmentés de 10 % (ligne 2 des résultats) ;
  • ligne 95 : la fonction [getArticleLentement] lit un article dans une requête qui dure une seconde : la fonction MySQL [sleep(1)] (ligne 98) attend une seconde avant de rendre 0. Elle simule une requête lente. La ligne courante du résultat devient un [Article] par la fonction d'extension [toArticle] du paquetage [commun] ;
  • lignes 42-50 : six lectures d'articles, exécutées en même temps :
    • ligne 43 : la fonction [measureTime] de la bibliothèque standard ([kotlin.time]) exécute son bloc et rend sa durée, un objet [Duration] ; [duree.toDouble(DurationUnit.SECONDS)] l'exprime en secondes. C'est plus lisible que les différences de [System.nanoTime()] du code Java ;
    • ligne 44 : [Executors.newVirtualThreadPerTaskExecutor] (Java 21, chapitre 8) crée un exécuteur qui lance chaque tâche dans un nouveau thread virtuel. Un [ExecutorService] est [AutoCloseable] depuis Java 19 : [use] le ferme à la fin du bloc (ligne 46), et sa méthode [close] attend la fin de toutes les tâches. La valeur du bloc de [use], la liste des tâches, devient celle de [taches] ;
    • ligne 45 : [map] transforme les identifiants 1 à 6 en six tâches soumises à l'exécuteur. Chaque tâche est une lambda convertie en [Callable<Article>] (conversion SAM, chapitre 7). L'interface est nommée explicitement, [Callable { … }], car [submit] existe aussi pour un [Runnable]. [submit] rend un [Future<Article>]. La lambda utilise directement la variable [id] : contrairement à Java, qui exigeait une copie de la variable de boucle, Kotlin n'a pas cette contrainte, et [id] est de toute façon un paramètre de lambda, différent pour chaque élément ;
    • ligne 47 : on affiche les articles dans l'ordre des tâches. [Future.get] rend le résultat de la tâche ; si la tâche a lancé une exception, [get] lance une [ExecutionException] qui l'encapsule (interceptée ligne 89). En Java, [get] obligeait aussi à déclarer ou intercepter l'exception contrôlée [InterruptedException] ; Kotlin n'impose rien ;
    • lignes 49-50 : la durée totale. Six requêtes d'une seconde exécutées l'une après l'autre dureraient six secondes ; exécutées en même temps avec six connexions, une seconde. Avec quatre connexions, il faut environ deux secondes (ligne 9 des résultats) : quatre threads virtuels obtiennent une connexion et exécutent leur requête en même temps, les deux autres attendent dans [getConnection] qu'une connexion soit rendue au pool, puis exécutent la leur. Le reste du temps est celui de l'ouverture des connexions du pool. Un thread qui attend une connexion trop longtemps (30 secondes par défaut, réglage [connectionTimeout] de HikariCP) reçoit une [SQLTransientConnectionException]. La fonction [format] utilise la locale de la machine, d'où la virgule décimale ;
  • lignes 52-57 : une valeur unique, la valeur du stock ;
  • lignes 62-84 : l'abandon d'une requête trop longue :
    • lignes 65-68 : une première connexion quitte le mode autocommit et modifie l'article n° 1 sans terminer sa transaction. Le SGBD a verrouillé la ligne modifiée jusqu'à la fin de la transaction ;
    • lignes 71-81 : une seconde connexion veut modifier la même ligne. Elle doit attendre que la première transaction se termine… ce qu'elle ne fera pas. [ordre.queryTimeout = 1] (ligne 75, [setQueryTimeout(1)] en Java) limite à une seconde la durée d'exécution de l'ordre : au bout d'une seconde, Connector/J demande au serveur d'interrompre la requête (ordre MySQL [KILL QUERY]), et [executeUpdate] lance une [SQLTimeoutException], ici de la classe [MySQLTimeoutException] du pilote (ligne 11 des résultats). Sans ce délai, la requête aurait attendu le délai d'attente de verrou de MySQL, 50 secondes par défaut, puis échoué. Le chronomètre est une marque de temps ([TimeSource.Monotonic.markNow()]) dont [elapsedNow] donne le temps écoulé ;
    • ligne 84 : la transaction [bloquante] n'est jamais validée : elle est annulée. La connexion est ensuite rendue au pool ;
  • lignes 87-89 : deux clauses [catch], qui remplacent la clause multiple de Java.

Les threads virtuels n'apportent rien à un petit programme console qui fait une chose à la fois : ils ne rendent pas une requête plus rapide. Mais ils permettent à une application serveur, comme les services web Spring Boot du chapitre 13 (où il suffit de la propriété [spring.threads.virtual.enabled=true]), de servir de nombreux utilisateurs à la fois avec un code JDBC ordinaire. La taille du pool de connexions devient alors le vrai réglage du parallélisme vis-à-vis de la base.

10.8.2. Les coroutines

Une application Kotlin écrite avec des coroutines (chapitre 9) rencontre le même problème : JDBC n'a pas de fonctions de suspension. Appeler [executeQuery] dans une coroutine exécutée par le dispatcher [Dispatchers.Default] bloquerait l'un des rares threads de ce dispatcher (autant que de cœurs du processeur), réservé aux calculs. La règle vue au chapitre 9 s'applique : un appel bloquant est enveloppé dans [withContext(Dispatchers.IO) { … }], qui l'exécute sur un thread du dispatcher [IO], conçu pour attendre, et suspend la coroutine appelante pendant ce temps. On obtient une fonction de suspension qui cache l'appel bloquant. Le programme [Jdbc08Coroutines.kt] reprend avec des coroutines la partie 2 du programme précédent, avec trois dispatchers différents. Il utilise la bibliothèque kotlinx-coroutines, déclarée dans le fichier [build.gradle.kts] du sous-projet par [implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")] :

package jdbc08coroutines

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import commun.Article
import commun.toArticle
import java.util.concurrent.Executors
import javax.sql.DataSource
import kotlin.time.DurationUnit
import kotlin.time.measureTime
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout

// JDBC est bloquant : des requêtes concurrentes exécutées par des coroutines

fun main() = runBlocking {
    // la source de données : un pool d'au plus 4 connexions vers la base [dbarticles]
    val config = HikariConfig().apply {
        jdbcUrl = "jdbc:mysql://localhost:3306/dbarticles"
        username = "root"
        password = ""
        maximumPoolSize = 4
    }
    HikariDataSource(config).use { dataSource ->
        // 1 - six requêtes concurrentes exécutées par les threads du dispatcher IO
        lireSixArticles(dataSource, Dispatchers.IO, "Dispatchers.IO")
        // 2 - pas plus de requêtes simultanées que de connexions : une vue limitée du dispatcher IO
        lireSixArticles(dataSource, Dispatchers.IO.limitedParallelism(4), "IO.limitedParallelism(4)")
        // 3 - un dispatcher qui exécute chaque coroutine dans un thread virtuel
        Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher().use { virtuel ->
            lireSixArticles(dataSource, virtuel, "threads virtuels")
        }
        // 4 - withTimeout n'interrompt pas un appel JDBC bloquant
        val duree = measureTime {
            try {
                withTimeout(300) { getArticleLentement(dataSource, 1, Dispatchers.IO) }
            } catch (ex: TimeoutCancellationException) {
                println("withTimeout(300) : ${ex.message}")
            }
        }
        println("... mais l'exception n'est arrivée qu'après ${duree.toString(DurationUnit.SECONDS, 1)}")
    }
}

// lecture concurrente des articles 1 à 6, chacun par une requête d'une seconde
private suspend fun lireSixArticles(
    dataSource: DataSource,
    dispatcher: CoroutineDispatcher,
    nom: String,
) {
    val duree = measureTime {
        val articles = coroutineScope {
            (1..6).map { id -> async { getArticleLentement(dataSource, id, dispatcher) } }.awaitAll()
        }
        println(articles.joinToString { it.nom })
    }
    println("  $nom : 6 requêtes d'une seconde exécutées en ${duree.toString(DurationUnit.SECONDS, 1)}")
}

// un article lu par une requête qui dure une seconde : une fonction de suspension
// qui exécute le code JDBC bloquant sur un thread du dispatcher reçu
private suspend fun getArticleLentement(
    dataSource: DataSource,
    id: Int,
    dispatcher: CoroutineDispatcher,
): Article =
    withContext(dispatcher) {
        dataSource.connection.use { connexion ->
            connexion.prepareStatement(
                "select id, nom, prix, stockactuel, stockminimum, sleep(1) from articles where id = ?"
            ).use { ordre ->
                ordre.setInt(1, id)
                ordre.executeQuery().use { resultat ->
                    resultat.next()
                    resultat.toArticle()
                }
            }
        }
    }

Résultats :

1
2
3
4
5
6
7
8
9
> gradlew -q :jdbc:run -Pmain=jdbc08coroutines.Jdbc08CoroutinesKt
vélo, pompe, arc, flèches - lot de 6, combinaison de plongée, bouteilles d'oxygène
  Dispatchers.IO : 6 requêtes d'une seconde exécutées en 2,1s
vélo, pompe, arc, flèches - lot de 6, combinaison de plongée, bouteilles d'oxygène
  IO.limitedParallelism(4) : 6 requêtes d'une seconde exécutées en 2,0s
vélo, pompe, arc, flèches - lot de 6, combinaison de plongée, bouteilles d'oxygène
  threads virtuels : 6 requêtes d'une seconde exécutées en 2,0s
withTimeout(300) : Timed out waiting for 300 ms
... mais l'exception n'est arrivée qu'après 1,0s

Commentaires :

  • ligne 70 : la lecture lente d'un article est maintenant une fonction de suspension. Son corps est un appel de [withContext(dispatcher)] (ligne 75) : le code JDBC, identique à celui du programme précédent, s'exécute sur un thread du dispatcher reçu en paramètre, pendant que la coroutine appelante est suspendue. La valeur rendue par le bloc, l'article, est celle de [withContext], et donc de la fonction. En pratique, le dispatcher serait fixé dans la fonction ([withContext(Dispatchers.IO)]) ; il est ici un paramètre pour comparer plusieurs dispatchers ;
  • ligne 54 : la fonction [lireSixArticles] lit les articles 1 à 6 de façon concurrente :
    • lignes 60-61 : dans une portée [coroutineScope] (concurrence structurée, chapitre 9), six coroutines sont lancées par [async], puis [awaitAll] attend leurs six résultats. Si l'une d'elles échouait, les autres seraient annulées et l'exception transmise à l'appelant ;
    • ligne 63 : les noms des articles, dans l'ordre des identifiants (lignes 2, 4 et 6 des résultats) ;
  • ligne 24 : le programme principal est une coroutine, exécutée par [runBlocking] ;
  • ligne 34 : les six coroutines exécutent leur code JDBC sur les threads du dispatcher [IO] (64 threads au plus). Comme avec les threads virtuels, quatre requêtes s'exécutent en même temps et deux attendent une connexion du pool : environ deux secondes (ligne 3 des résultats), plus le temps d'ouverture des connexions du pool, qui ne se produit qu'une fois ;
  • ligne 36 : [limitedParallelism(4)] crée une vue du dispatcher [IO] qui n'exécute pas plus de quatre blocs à la fois (chapitre 9). Avec un parallélisme égal à la taille du pool, aucun thread n'attend dans [getConnection] : les coroutines en surnombre attendent, suspendues, qu'un des quatre emplacements se libère, ce qui ne coûte aucun thread. C'est la bonne pratique lorsque de nombreuses coroutines accèdent à la base : un dispatcher dont le parallélisme est celui du pool de connexions. La durée est la même (ligne 5) ;
  • lignes 38-39 : un dispatcher qui exécute chaque bloc dans un nouveau thread virtuel, construit à partir de l'exécuteur de Java par [asCoroutineDispatcher] (chapitre 9) et fermé par [use]. Coroutines et threads virtuels se combinent : les coroutines structurent le code, les threads virtuels rendent l'attente bon marché (ligne 7) ;
  • lignes 41-49 : une mise en garde. [withTimeout(300)] annule la coroutine au bout de 300 ms, et l'exception [TimeoutCancellationException] est bien lancée (ligne 8 des résultats)… mais au bout d'une seconde (ligne 9). L'annulation des coroutines est coopérative (chapitre 9) : elle n'est constatée qu'aux points de suspension. Or le code JDBC, bloqué dans [executeQuery], n'en a aucun : il va jusqu'au bout de sa requête, et [withContext] ne rend la main qu'ensuite, en lançant l'exception. La requête n'a pas été interrompue sur le serveur. Pour limiter vraiment la durée d'une requête, c'est le délai de JDBC qu'il faut utiliser, [ordre.queryTimeout = n], comme dans le programme précédent.

On retiendra que les coroutines n'ajoutent pas d'asynchronisme à JDBC : elles déplacent les appels bloquants sur des threads prévus pour attendre, ce qui permet d'intégrer l'accès à la base dans une application écrite avec des coroutines (un serveur Ktor, ou les contrôleurs à fonctions de suspension de Spring, par exemple). Le gain réel en nombre d'utilisateurs servis vient, comme en Java, des threads virtuels et de la taille du pool. Le chapitre 9 a comparé plus généralement les deux modèles.

10.9. L'exécution par lots

Chaque exécution d'un ordre SQL est un aller-retour entre l'application et le serveur. Lorsqu'on doit exécuter plusieurs ordres SQL, on peut les envoyer au SGBD en un seul échange, sous la forme d'un lot (« batch »). JDBC le permet depuis sa version 2.0 :

  • pour un [Statement], [addBatch(sql)] ajoute un ordre SQL au lot ;
  • pour un [PreparedStatement], [addBatch()] ajoute au lot les valeurs actuelles des paramètres : le lot contient le même ordre SQL exécuté avec différentes valeurs ;
  • [executeBatch] exécute le lot et rend un tableau d'entiers ([IntArray] en Kotlin) : le nombre de lignes modifiées par chaque ordre du lot. Si un ordre échoue, elle lance une [BatchUpdateException] dont la méthode [getUpdateCounts] (la propriété [updateCounts] en Kotlin) donne les résultats des ordres exécutés.

Le programme [Jdbc09.kt] utilise les lots :

package jdbc09

import commun.transaction
import java.math.BigDecimal
import java.sql.DriverManager
import java.sql.SQLException
import kotlin.time.measureTimedValue

// l'exécution par lots : plusieurs ordres SQL envoyés au SGBD en un seul échange

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    try {
        DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
            println("Le pilote sait exécuter des lots : ${connexion.metaData.supportsBatchUpdates()}")

            // 1 - un lot de trois ordres de mise à jour différents
            connexion.createStatement().use { ordre ->
                ordre.addBatch(
                    "insert into articles(nom, prix, stockactuel, stockminimum) values('canoë', 800, 5, 1)"
                )
                ordre.addBatch(
                    "update articles set stockactuel = stockactuel + 10 where stockactuel < stockminimum"
                )
                ordre.addBatch("delete from articles where nom = 'pompe'")
                val nbLignes: IntArray = ordre.executeBatch()
                println("Lignes modifiées par chaque ordre : ${nbLignes.contentToString()}, "
                    + "total : ${nbLignes.sum()}")
            }
        }
        // 2 - 1000 insertions par un lot du même ordre paramétré, sans puis avec réécriture du lot
        insererLot(URL, 1000)
        insererLot("$URL?rewriteBatchedStatements=true", 1000)
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }

    // 3 - deux select envoyés en un seul échange : un ordre, deux résultats successifs
    try {
        val url = "$URL?allowMultiQueries=true"
        DriverManager.getConnection(url, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
            connexion.createStatement().use { ordre ->
                ordre.execute(
                    """
                    select nom, stockactuel from articles where stockactuel < stockminimum + 5;
                    select count(*), sum(prix * stockactuel) from articles
                    """.trimIndent()
                )
                println("Articles à surveiller :")
                ordre.resultSet.use { resultat ->
                    while (resultat.next()) {
                        println("  ${resultat.getString(1)} : ${resultat.getInt(2)}")
                    }
                }
                // passage au résultat suivant
                ordre.getMoreResults()
                ordre.resultSet.use { resultat ->
                    resultat.next()
                    val (nombre, valeur) = resultat.getLong(1) to resultat.getBigDecimal(2)
                    println("$nombre articles, valeur du stock : $valeur euros")
                }
            }
        }
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

// insertion de n articles par un lot, dans une transaction, puis suppression de ces articles
private fun insererLot(url: String, n: Int) {
    DriverManager.getConnection(url, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        // measureTimedValue rend la valeur du bloc et sa durée d'exécution
        val (nbLignes, duree) = measureTimedValue {
            connexion.transaction {
                prepareStatement(
                    "insert into articles(nom, prix, stockactuel, stockminimum) values(?, ?, ?, ?)"
                ).use { ordre ->
                    for (i in 1..n) {
                        ordre.setString(1, "lot-$i")
                        ordre.setBigDecimal(2, BigDecimal.TEN)
                        ordre.setInt(3, i)
                        ordre.setInt(4, 0)
                        // les valeurs des paramètres sont ajoutées au lot
                        ordre.addBatch()
                    }
                    ordre.executeBatch()
                }
            }
        }
        val base = url.substringAfterLast('/')
        val ms = duree.inWholeMilliseconds
        println("${nbLignes.size} insertions ($base) en $ms ms : ${nbLignes.take(3)}...")
        // on remet la table dans son état précédent
        connexion.createStatement().use { ordre ->
            ordre.executeUpdate("delete from articles where nom like 'lot-%'")
        }
    }
}

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc09.Jdbc09Kt
Le pilote sait exécuter des lots : true
Lignes modifiées par chaque ordre : [1, 1, 1], total : 3
1000 insertions (dbarticles) en 535 ms : [1, 1, 1]...
1000 insertions (dbarticles?rewriteBatchedStatements=true) en 37 ms : [-2, -2, -2]...
Articles à surveiller :
  arc : 4
  flèches - lot de 6 : 22
  canoë : 5
6 articles, valeur du stock : 17200.00 euros

Commentaires :

  • ligne 18 : la méthode [supportsBatchUpdates] des métadonnées de la connexion ([connexion.metaData], la propriété qui correspond à [getMetaData()]) indique si le pilote sait exécuter des lots ;
  • lignes 20-31 : un lot de trois ordres de mise à jour différents, ajoutés au lot d'un [Statement] par [addBatch] :
    • ligne 29 : les trois ordres sont exécutés. [executeBatch] rend le nombre de lignes modifiées par chacun : une insertion ([canoë]), une mise à jour (le stock des flèches, inférieur au stock minimum) et une suppression ([pompe]). Le type [IntArray] (le [int[]] de Java), inféré, est écrit ici pour l'explication ;
    • lignes 30-31 : [contentToString] affiche le contenu du tableau (le [toString] d'un tableau n'affiche que son adresse), et [sum], fonction d'extension des tableaux, calcule la somme de ses éléments (ligne 3 des résultats). Java passait par [Arrays.toString] et [Arrays.stream(…).sum()] ;
  • lignes 35-36 : la fonction [insererLot] est appelée deux fois, avec deux URL différentes ;
  • ligne 73 : la fonction [insererLot] insère [n] articles avec un lot :
    • ligne 76 : la fonction [measureTimedValue] de [kotlin.time] exécute son bloc et rend un objet [TimedValue], une classe de données qui contient la valeur rendue par le bloc et la durée de son exécution ; la déstructuration les range dans deux variables ;
    • ligne 77 : les insertions sont faites dans une transaction, gérée par la fonction [transaction] du paquetage [commun], ce qui est aussi bien plus rapide que de valider chacune d'elles ;
    • lignes 81-87 : pour chaque article, les paramètres reçoivent leur valeur, puis [addBatch()] ajoute ces valeurs au lot ;
    • ligne 89 : le lot est exécuté ; son résultat est celui du bloc de [use], puis du bloc de [transaction], qui valide la transaction avant de le rendre, et enfin du bloc de [measureTimedValue] ;
    • lignes 93-95 : on affiche le nombre d'insertions, la base (ce qui suit le dernier [/] de l'URL, par [substringAfterLast]), la durée en millisecondes et les trois premiers éléments du tableau rendu par [executeBatch] ([take(3)] rend une liste, que [println] affiche entre crochets) ;
    • lignes 96-98 : les articles insérés sont supprimés, pour remettre la table dans son état précédent. La fonction [transaction] ayant rétabli le mode autocommit, la suppression est validée dès son exécution ;
  • ligne 4 des résultats : avec l'URL simple, le pilote MySQL envoie les 1000 insertions l'une après l'autre : le lot n'apporte presque rien. Chaque élément du tableau vaut 1 ;
  • ligne 5 des résultats : avec le paramètre [rewriteBatchedStatements=true] de l'URL, Connector/J réécrit le lot en quelques ordres [insert into articles(…) values (…), (…), (…)…], qui insèrent chacun de nombreuses lignes. L'exécution est de dix à vingt fois plus rapide. Les éléments du tableau valent alors -2, la valeur de la constante [Statement.SUCCESS_NO_INFO] : l'ordre a réussi, mais le pilote ne sait pas combien de lignes chaque élément du lot a modifiées. C'est un réglage à connaître : sans lui, les lots n'ont guère d'intérêt avec MySQL. Les durées varient d'une exécution à l'autre ; seul leur rapport est significatif ;
  • lignes 41-63 : deux ordres [select] envoyés en un seul échange :
    • ligne 43 : la connexion est ouverte avec le paramètre [allowMultiQueries=true], qui autorise plusieurs ordres SQL séparés par des points-virgules dans une même requête. Il est désactivé par défaut, nous l'avons vu, pour limiter la portée des injections SQL ;
    • lignes 46-50 : les deux ordres, écrits dans une chaîne brute sur deux lignes, sont exécutés par [execute], qui accepte un ordre quelconque ;
    • ligne 53 : la propriété [resultSet] ([getResultSet()]) rend le résultat du premier ordre ;
    • ligne 59 : [getMoreResults] passe au résultat suivant (elle ferme le résultat courant et rend [true] si le résultat suivant est un [ResultSet]). Kotlin permettrait d'écrire [ordre.moreResults], comme pour une propriété ; mais lire une propriété ne devrait pas changer l'état d'un objet, et l'appel explicite de la méthode dit mieux ce qu'il fait ;
    • lignes 60-63 : le résultat du second ordre : une ligne de deux colonnes, rangées dans deux variables par la déstructuration d'une paire ([a to b]).

Les lots sont utiles pour les insertions et mises à jour en grand nombre (importation de données, par exemple). Hibernate (chapitre 11) sait les utiliser lui aussi.

10.10. Le mode déconnecté : CachedRowSet

Le mode déconnecté de JDBC repose sur l'interface [javax.sql.rowset.CachedRowSet], dérivée de [ResultSet] : un [CachedRowSet] est un résultat de requête copié en mémoire, que l'on peut parcourir dans les deux sens et modifier hors connexion, puis dont on renvoie les modifications à la base. Le programme [Jdbc10.kt] le montre :

package jdbc10

import java.math.BigDecimal
import java.sql.DriverManager
import java.sql.SQLException
import javax.sql.rowset.RowSetProvider

// le mode déconnecté : une copie en mémoire des lignes, modifiée hors connexion puis renvoyée à la base

private const val URL = "jdbc:mysql://localhost:3306/dbarticles"
private const val UTILISATEUR = "root"
private const val MOT_DE_PASSE = ""

fun main() {
    try {
        // la fabrique des RowSet de la plateforme Java
        val fabrique = RowSetProvider.newFactory()
        fabrique.createCachedRowSet().use { articles ->
            // les paramètres de connexion et la requête
            articles.url = URL
            articles.username = UTILISATEUR
            articles.password = MOT_DE_PASSE
            articles.command = "select id, nom, prix, stockactuel, stockminimum from articles"
            // la table à mettre à jour et sa clé primaire (colonne n° 1)
            articles.tableName = "articles"
            articles.keyColumns = intArrayOf(1)
            // connexion, copie des lignes en mémoire, déconnexion
            articles.execute()
            println("${articles.size()} lignes copiées en mémoire")

            // travail hors connexion : réapprovisionnement des articles en rupture de stock
            while (articles.next()) {
                val stockActuel = articles.getInt("stockactuel")
                val stockMinimum = articles.getInt("stockminimum")
                if (stockActuel < stockMinimum) {
                    articles.updateInt("stockactuel", stockMinimum + 10)
                    articles.updateRow()
                    val nom = articles.getString("nom")
                    println("[$nom] : stock $stockActuel -> ${articles.getInt("stockactuel")}")
                }
            }
            // ajout d'une ligne : l'id 0 demande à MySQL d'en générer un
            with(articles) {
                moveToInsertRow()
                updateInt("id", 0)
                updateString("nom", "canoë")
                updateBigDecimal("prix", BigDecimal("800"))
                updateInt("stockactuel", 5)
                updateInt("stockminimum", 1)
                insertRow()
                moveToCurrentRow()
            }
            // suppression de la 2e ligne
            articles.absolute(2)
            println("suppression de [${articles.getString("nom")}]")
            articles.deleteRow()

            // la base n'a pas encore été modifiée
            afficherArticles("Base avant acceptChanges :")
            // connexion, envoi des modifications dans une transaction, déconnexion
            DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
                connexion.autoCommit = false
                articles.acceptChanges(connexion)
            }
        }
        afficherArticles("Base après acceptChanges :")
    } catch (ex: SQLException) {
        println("Erreur d'accès à la base de données (${ex.message})")
    }
}

// affichage du contenu de la table, lu en mode connecté
private fun afficherArticles(titre: String) {
    DriverManager.getConnection(URL, UTILISATEUR, MOT_DE_PASSE).use { connexion ->
        connexion.createStatement().use { ordre ->
            val requete = "select id, nom, prix, stockactuel, stockminimum from articles"
            ordre.executeQuery(requete).use { resultat ->
                println(titre)
                while (resultat.next()) {
                    val ligne = with(resultat) {
                        "${getInt(1)} ${getString(2)} ${getBigDecimal(3)} ${getInt(4)} ${getInt(5)}"
                    }
                    println("  $ligne")
                }
            }
        }
    }
}

Résultats :

> gradlew -q :jdbc:run -Pmain=jdbc10.Jdbc10Kt
6 lignes copiées en mémoire
[flèches - lot de 6] : stock 12 -> 30
suppression de [pompe]
Base avant acceptChanges :
  1 vélo 500.00 10 5
  2 pompe 10.00 10 2
  3 arc 600.00 4 1
  4 flèches - lot de 6 100.00 12 20
  5 combinaison de plongée 300.00 8 2
  6 bouteilles d'oxygène 120.00 10 5
Base après acceptChanges :
  1 vélo 500.00 10 5
  3 arc 600.00 4 1
  4 flèches - lot de 6 100.00 30 20
  5 combinaison de plongée 300.00 8 2
  6 bouteilles d'oxygène 120.00 10 5
  7 canoë 800.00 5 1

Commentaires :

  • lignes 17-18 : [RowSetProvider.newFactory] (Java 7) rend une fabrique ([RowSetFactory]) des différents types de RowSet du JDK, dont [createCachedRowSet] crée un [CachedRowSet]. Un [CachedRowSet] est une ressource ([AutoCloseable], comme tout [ResultSet]), fermée par [use] ;
  • lignes 20-23 : le [CachedRowSet] reçoit les paramètres de connexion et la requête qui le remplira. Les méthodes [setUrl], [setUsername], [setPassword] et [setCommand] ont chacune un accesseur [getXxx] associé : Kotlin les présente comme des propriétés ;
  • lignes 25-26 : pour pouvoir renvoyer les modifications à la base, il doit connaître la table modifiée et sa clé primaire, ici la colonne n° 1, [id]. [intArrayOf(1)] crée le tableau [int[]] attendu par la méthode Java ;
  • ligne 28 : [execute] ouvre une connexion, exécute la requête, copie toutes les lignes du résultat en mémoire et ferme la connexion. La suite du travail se fait hors connexion. [articles.size()] (ligne 29) donne le nombre de lignes : [size] n'étant pas un accesseur [getSize], elle reste une méthode ;
  • lignes 32-39 : un [CachedRowSet] est un [ResultSet] : on le parcourt avec [next] et on lit les colonnes avec [getXxx]. Les méthodes [updateXxx] modifient une colonne de la ligne courante et [updateRow] enregistre la modification… dans la copie en mémoire. Le stock des flèches, inférieur au stock minimum, est porté à 30 (ligne 3 des résultats) ;
  • lignes 43-51 : l'ajout d'une ligne. Les huit appels portent tous sur le [CachedRowSet] : la fonction [with] en fait le receveur du bloc, ce qui évite de répéter [articles.]. [moveToInsertRow] place le curseur sur une ligne spéciale, la « ligne d'insertion », dont on donne les colonnes par [updateXxx] ; [insertRow] l'ajoute au [CachedRowSet] et [moveToCurrentRow] ramène le curseur où il était. Le [CachedRowSet] exige une valeur pour toutes les colonnes [NOT NULL], y compris [id] : la valeur 0, insérée dans une colonne [AUTO_INCREMENT], demande à MySQL d'en générer une ;
  • lignes 54-56 : [absolute(2)] place le curseur sur la deuxième ligne, ce que permet un [CachedRowSet], que l'on peut parcourir dans tous les sens, et [deleteRow] la supprime ;
  • ligne 59 : la base n'a pas encore été modifiée (lignes 5-11 des résultats) ;
  • lignes 61-63 : [acceptChanges] renvoie les modifications à la base : un [update], un [insert] et un [delete], exécutés dans une transaction validée à la fin. Pour chaque ligne modifiée, l'implémentation du JDK vérifie que la ligne de la base n'a pas été modifiée par quelqu'un d'autre depuis sa lecture (contrôle dit « optimiste ») ; sinon elle lance une [SyncProviderException]. La version sans paramètre de [acceptChanges] ouvre elle-même la connexion, mais la laisse en mode autocommit puis appelle [commit], ce que refuse Connector/J ; on lui passe donc une connexion qui a quitté le mode autocommit ;
  • ligne 66 : la base a été modifiée (lignes 12-18 des résultats).

Le [CachedRowSet] a l'avantage d'être une API standard du JDK, mais on voit ses limites : on manipule des lignes et des noms ou numéros de colonnes, et non des objets métier ; les modifications sont faites par une implémentation dont on maîtrise mal le SQL. C'est pourquoi il est peu utilisé aujourd'hui : le travail sur des objets en mémoire avec enregistrement ultérieur des modifications est le domaine des ORM comme Hibernate (chapitre 11).

10.11. Application exemple - version 6

On reprend l'application exemple [Impots]. La dernière version, la version 5, a été étudiée au chapitre 6. C'était une application à trois couches dont la couche [dao] trouvait ses données dans un fichier texte, un fichier JSON ou dans son propre code, et dont les couches étaient intégrées par le conteneur d'injection de dépendances de Spring. Nous écrivons une nouvelle couche [dao] qui va chercher ses données dans une base de données MySQL :

Image

Nous réutilisons les sous-projets de la version 5 tels quels, par des dépendances Gradle, et n'écrivons que ce qui est nouveau : la couche [dao] et le programme qui intègre les couches.

10.11.1. La base de données

Les tranches d'impôt sont placées dans la table [tranches] d'une base MySQL [dbimpots], créée par le script [sql/dbimpots.sql] :

-- création de la base [dbimpots] de l'application [Impots] (version 6)
-- exécution : mysql -uroot < dbimpots.sql

-- le script est écrit en UTF-8
SET NAMES utf8mb4;

-- la base est recréée à chaque exécution du script
DROP DATABASE IF EXISTS dbimpots;
CREATE DATABASE dbimpots CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE dbimpots;

-- la table des tranches d'impôt (barème 2004 sur les revenus 2003)
CREATE TABLE tranches (
    id      INT PRIMARY KEY AUTO_INCREMENT,
    limite  DECIMAL(10,2) NOT NULL,
    coeffr  DECIMAL(6,3) NOT NULL,
    coeffn  DECIMAL(10,2) NOT NULL
);
INSERT INTO tranches (limite, coeffr, coeffn) VALUES
    (4962, 0, 0),
    (8382, 0.068, 291.09),
    (14753, 0.191, 1322.92),
    (23888, 0.283, 2668.39),
    (38868, 0.374, 4846.98),
    (47932, 0.426, 6883.66),
    (0, 0.481, 9505.54);

-- un utilisateur propre à l'application, qui n'a de droits que sur la base [dbimpots]
CREATE USER IF NOT EXISTS 'admimpots'@'localhost' IDENTIFIED BY 'mdpimpots';
GRANT ALL PRIVILEGES ON dbimpots.* TO 'admimpots'@'localhost';
  • lignes 13-17 : la table [tranches] a quatre colonnes : la clé primaire [id], générée par le SGBD, puis les trois colonnes [limite], [coeffr] et [coeffn] d'une tranche d'impôt, de type [DECIMAL] : ce sont des montants et des coefficients qui doivent être exacts. Ils seront lus en Kotlin sous la forme de [BigDecimal], le type des propriétés de la classe de données [TrancheImpot] ;
  • lignes 19-26 : les sept tranches du barème 2004 sur les revenus 2003, utilisé depuis le début du cours (c'est un exercice : le barème n'est pas à jour). Les tranches sont insérées dans l'ordre croissant des limites, et la colonne [id] reflète cet ordre ;
  • lignes 29-30 : la création d'un utilisateur MySQL [admimpots], de mot de passe [mdpimpots], qui reçoit tous les droits ([GRANT ALL PRIVILEGES]) sur tous les objets de la base [dbimpots] ([dbimpots.*]), et seulement sur eux. L'application travaillera avec cet utilisateur plutôt qu'avec l'administrateur [root] : si son mot de passe venait à être connu, seule la base [dbimpots] serait exposée. [IF NOT EXISTS] permet d'exécuter le script plusieurs fois.

Le script est exécuté par l'administrateur :

> mysql -uroot < dbimpots.sql

puis on vérifie que l'utilisateur [admimpots] a accès aux tranches (l'option [-p] seule fait demander le mot de passe, qui n'est pas affiché lorsqu'on le tape) :

> mysql -uadmimpots -p -t dbimpots -e "select * from tranches"
Enter password:
+----+----------+--------+---------+
| id | limite   | coeffr | coeffn  |
+----+----------+--------+---------+
|  1 |  4962.00 |  0.000 |    0.00 |
|  2 |  8382.00 |  0.068 |  291.09 |
|  3 | 14753.00 |  0.191 | 1322.92 |
|  4 | 23888.00 |  0.283 | 2668.39 |
|  5 | 38868.00 |  0.374 | 4846.98 |
|  6 | 47932.00 |  0.426 | 6883.66 |
|  7 |     0.00 |  0.481 | 9505.54 |
+----+----------+--------+---------+

10.11.2. Le projet ImpotsV6

La version 6 est formée de deux sous-projets Gradle, placés dans le dossier [10-jdbc/ImpotsV6] de la construction du chapitre, qui utilisent les trois sous-projets de la version 5 :

kotlin-oct-2026/
  06-architectures/                       construction Gradle du chapitre 6
    settings.gradle.kts
    ImpotsV5/                             la version 5, réutilisée
      impots-dao/                         entités, interface IImpotDao, implémentations de la version 5
      impots-metier/                      couche [metier]
      impots-ui/                          couche [ui] (objet Dialogue) et programmes de la version 5
  10-jdbc/                                construction Gradle du chapitre 10
    settings.gradle.kts                   déclare les sous-projets et la construction composite
    build.gradle.kts                      projet racine
    sql/
      dbimpots.sql                        script de création de la base
    jdbc/                                 les programmes Jdbc01 à Jdbc10
    ImpotsV6/
      impots-dao-mysql/                   la nouvelle couche [dao]
        build.gradle.kts
        src/main/kotlin/impots/dao/
          MySqlImpot.kt
        src/test/kotlin/impots/dao/
          MySqlImpotTests.kt              tests JUnit de la nouvelle couche [dao]
      impots-ui-mysql/                    intégration des couches
        build.gradle.kts
        impots.properties
        impots-base.properties
        impots-motdepasse.properties
        impots-serveur.properties
        impots-requete.properties
        impots-postgresql.properties
        src/main/kotlin/impots/ui/mysql/
          Main.kt

Les sous-projets de la version 5 ne font pas partie de la construction du chapitre 10 : ce sont des dépendances, comme le pilote JDBC ou Spring. Le cours Java, construit avec Maven, devait d'abord les installer dans le dépôt local de la machine ([mvn install]). Gradle offre un mécanisme plus direct, la construction composite (« composite build ») : le fichier [settings.gradle.kts] du chapitre 10, déjà rencontré, inclut la construction du chapitre 6 tout entière :

// chapitre 10 : l'accès aux bases de données avec JDBC

// Gradle télécharge lui-même le JDK demandé par jvmToolchain(25) s'il ne le trouve pas sur le poste
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

rootProject.name = "jdbc-chapitre"

// les programmes JDBC du chapitre
include("jdbc")
// la version 6 de l'application [Impots] : une couche [dao] qui lit une base MySQL
include("ImpotsV6:impots-dao-mysql", "ImpotsV6:impots-ui-mysql")

// les couches [metier] et [ui] de la version 5 (chapitre 6) sont réutilisées : construction composite
includeBuild("../06-architectures")
  • lignes 3-6 : le plugin qui permet à Gradle de télécharger lui-même le JDK 25 s'il ne le trouve pas sur le poste (chapitre 2) ;
  • ligne 11 : le sous-projet des programmes [Jdbc01] à [Jdbc10] ;
  • ligne 13 : les deux sous-projets de la version 6. Le dossier [ImpotsV6] n'a pas de fichier [build.gradle.kts] : c'est un simple dossier de regroupement, comme [ImpotsV5] au chapitre 6 ;
  • ligne 16 : la construction du chapitre 6 est incluse. Lorsqu'un sous-projet du chapitre 10 déclare une dépendance de coordonnées [exemples.impots:impots-metier:5.0], Gradle constate qu'un sous-projet de la construction incluse a ces coordonnées (le [group] déclaré dans son [build.gradle.kts], chapitre 6, et son nom, [impots-metier] ; la version n'intervient pas) : il le substitue à la bibliothèque, le compile si nécessaire et utilise le résultat. Aucune installation préalable n'est nécessaire, et une modification de la version 5 serait prise en compte à la construction suivante.

On mesure ici l'intérêt de l'architecture en couches. Les couches [metier] et [ui] ne connaissent que l'interface [IImpotDao], définie dans le sous-projet [impots-dao] de la version 5 ; la nouvelle couche [dao] implémente cette interface dans un nouveau sous-projet. Aucune ligne de la version 5 n'est modifiée.

10.11.3. La couche [dao]

Le fichier [build.gradle.kts] du sous-projet [impots-dao-mysql] est le suivant :

// ImpotsV6 : le sous-projet impots-dao-mysql, une bibliothèque (la couche [dao] qui lit une base MySQL)
plugins {
    kotlin("jvm")
    `java-library`
}

// les coordonnées de la bibliothèque : exemples.impots:impots-dao-mysql:6.0
group = "exemples.impots"
version = "6.0"

repositories {
    mavenCentral()
}

dependencies {
    // les entités et l'interface IImpotDao de la version 5 (construction composite ../06-architectures) :
    // elles font partie de l'API de la nouvelle couche [dao]
    api("exemples.impots:impots-dao:5.0")
    // le pilote JDBC de MySQL : le code ne l'utilise qu'à l'exécution
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
    // pour les tests : la couche [metier] de la version 5, kotlin.test et JUnit
    testImplementation("exemples.impots:impots-metier:5.0")
    testImplementation(kotlin("test"))
    testImplementation(platform("org.junit:junit-bom:6.0.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
    }
}
  • lignes 3-4 : le greffon Kotlin, dont la version est donnée par le projet racine, et le greffon des bibliothèques, qui ajoute la configuration [api] ;
  • lignes 8-9 : les coordonnées de la nouvelle couche, [exemples.impots:impots-dao-mysql:6.0] ;
  • ligne 18 : le sous-projet [impots-dao] de la version 5, qui contient les entités ([TrancheImpot], [ImpotException]) et l'interface [IImpotDao]. La dépendance est déclarée par ses coordonnées, et la construction composite la résout. Elle est déclarée avec [api] et non [implementation] : la classe [MySqlImpot] implémente [IImpotDao] et sa propriété [tranchesImpot] est de type [List<TrancheImpot>], si bien qu'un projet qui utilise [MySqlImpot] a besoin de ces types pour compiler ;
  • ligne 20 : le pilote JDBC de MySQL, dans la configuration [runtimeOnly] : il n'est pas utilisé à la compilation, mais il est présent à l'exécution et pendant les tests. C'est une garantie : si le code utilisait par erreur une classe du pilote, il ne compilerait pas. La couche [dao] ne dépend que de JDBC. Les projets qui dépendent de [impots-dao-mysql] reçoivent le pilote à l'exécution, par transitivité ;
  • lignes 22-26 : pour les tests seulement, la couche [metier] de la version 5, [kotlin.test] et JUnit 6 ;
  • lignes 29-40 : la configuration du compilateur et des tests, la même qu'au chapitre 6.

La classe [MySqlImpot] implémente l'interface [IImpotDao] :

package impots.dao

import impots.entites.ImpotException
import impots.entites.TrancheImpot
import java.math.BigDecimal
import java.sql.DriverManager
import java.sql.ResultSet
import java.sql.SQLException

// une couche [dao] qui lit les tranches d'impôt dans une base MySQL
// la requête a une valeur par défaut : un seul constructeur suffit
class MySqlImpot(
    url: String,
    utilisateur: String,
    motDePasse: String,
    requete: String = REQUETE_PAR_DEFAUT,
) : IImpotDao {

    // tranches d'impôt, lues une fois pour toutes à la construction de l'objet
    override val tranchesImpot: List<TrancheImpot>

    init {
        val tranches: List<TrancheImpot> = try {
            DriverManager.getConnection(url, utilisateur, motDePasse).use { connexion ->
                connexion.createStatement().use { ordre ->
                    ordre.executeQuery(requete).use { resultat ->
                        // une tranche par ligne du résultat : limite, coeffR, coeffN
                        buildList {
                            while (resultat.next()) {
                                add(lireTranche(resultat))
                            }
                        }
                    }
                }
            }
        } catch (e: SQLException) {
            // erreur d'accès à la base : on l'encapsule dans une ImpotException
            throw ImpotException("Erreur d'accès à la base des tranches d'impôt", e, ACCES)
        }
        // une table vide est aussi une erreur
        if (tranches.isEmpty()) {
            throw ImpotException("La base ne contient aucune tranche d'impôt", code = DONNEES)
        }
        // buildList rend une liste en lecture seule
        tranchesImpot = tranches
    }

    companion object {
        // codes d'erreur propres à cette implémentation
        const val ACCES = 101
        const val DONNEES = 102

        // la requête par défaut : les tranches doivent être rendues dans l'ordre croissant des limites
        const val REQUETE_PAR_DEFAUT = "select limite, coeffr, coeffn from tranches order by id"

        // la tranche de la ligne courante : trois colonnes numériques, sans valeur NULL
        private fun lireTranche(resultat: ResultSet): TrancheImpot {
            // getBigDecimal rend null pour une valeur SQL NULL : le type est donc BigDecimal?
            val colonnes: List<BigDecimal?> = try {
                (1..3).map { resultat.getBigDecimal(it) }
            } catch (e: SQLException) {
                // une colonne absente ou non numérique
                throw ImpotException("Les tranches d'impôt de la base sont incorrectes", e, DONNEES)
            }
            val (limite, coeffR, coeffN) = colonnes
            if (limite == null || coeffR == null || coeffN == null) {
                throw ImpotException("Une tranche d'impôt de la base a une valeur NULL", code = DONNEES)
            }
            // ici, les trois valeurs sont de type BigDecimal (transtypage intelligent)
            return TrancheImpot(limite, coeffR, coeffN)
        }
    }
}
  • ligne 1 : la classe est dans le paquetage [impots.dao], comme les autres implémentations de [IImpotDao], bien qu'elle soit dans un autre sous-projet ;
  • lignes 12-17 : le constructeur primaire reçoit les paramètres de connexion et la requête qui rend les tranches. La requête a une valeur par défaut, [REQUETE_PAR_DEFAUT] : là où Java avait besoin de deux constructeurs, le second appelant le premier par [this(…)], Kotlin n'en a qu'un. Les paramètres ne sont pas des propriétés (ils n'ont ni [val] ni [var]) : ils ne servent qu'à la construction de l'objet, et le mot de passe n'est pas conservé ;
  • ligne 20 : la propriété de l'interface [IImpotDao], que redéfinit la classe. Elle n'a pas de valeur initiale : elle reçoit sa valeur dans le bloc [init], une fois pour toutes ;
  • ligne 22 : les tranches sont lues une fois pour toutes, à la construction de l'objet : la couche [dao] est un singleton dans l'application, et la base n'est plus interrogée ensuite. Une connexion ouverte par [DriverManager] suffit donc : un pool n'aurait aucun intérêt ;
  • lignes 23-26 : la connexion, l'ordre SQL et le résultat, fermés par trois [use] imbriqués, comme dans les exemples précédents. Le [try] est une expression : sa valeur, celle des blocs imbriqués, devient celle de [tranches] ;
  • lignes 28-30 : la fonction [buildList] de la bibliothèque standard construit une liste : dans son bloc, [this] est une liste modifiable, à laquelle [add] ajoute une tranche par ligne du résultat ; elle rend ensuite cette liste sous le type [List], en lecture seule. Le type de la variable, [List<TrancheImpot>], est indiqué explicitement : le compilateur doit connaître le type des éléments de la liste avant d'analyser le bloc ;
  • lignes 36-38 : une erreur d'accès à la base est encapsulée dans une [ImpotException] de code [ACCES], comme le faisaient [FileImpot] et [JsonImpot] avec les erreurs d'entrées / sorties. La couche [ui] n'a pas à connaître les [SQLException] de JDBC ;
  • lignes 41-42 : une table vide est une erreur de données. L'[ImpotException] du chapitre 6 n'a qu'un constructeur, [ImpotException(message, cause = null, code = 0)] : pour donner un code sans cause, l'argument [code] doit être nommé ;
  • ligne 45 : la propriété reçoit la liste des tranches ;
  • lignes 48-51 : les codes d'erreur propres à cette implémentation, sur le modèle de ceux de [FileImpot] et [JsonImpot] : 101 pour une erreur d'accès à la base, 102 pour des données incorrectes. Ce sont des constantes ([const val]) de l'objet compagnon : on les désigne par [MySqlImpot.ACCES] ;
  • ligne 54 : la requête qui rend les tranches. La clause [order by id] est importante : la couche [metier] attend les tranches dans l'ordre croissant des limites, et un [select] sans [order by] rend les lignes dans un ordre que SQL ne garantit pas ;
  • ligne 57 : la lecture d'une tranche, une fonction privée de l'objet compagnon (elle n'utilise aucune propriété de l'objet) :
    • lignes 59-60 : [getBigDecimal(i)] lit la colonne n° i sous la forme d'un [BigDecimal]. Le type déclaré, [List<BigDecimal?>], est important : pour Kotlin, une méthode Java rend une valeur de type « plateforme », [BigDecimal!], dont il ne sait pas si elle peut être [null]. Or [getBigDecimal] rend [null] pour une valeur SQL [NULL] : en déclarant des éléments de type [BigDecimal?], nous obligeons le compilateur à nous faire vérifier ce cas ;
    • lignes 61-63 : une requête qui rend moins de trois colonnes, ou une colonne non numérique, provoque une [SQLException] dans [getBigDecimal] : c'est aussi une erreur de données ;
    • lignes 65-67 : la liste est déstructurée en trois variables ; une valeur [null] est une erreur de données ;
    • ligne 70 : après le test, les trois variables sont de type [BigDecimal] et non plus [BigDecimal?] (transtypage intelligent) : le compilateur accepte de les passer au constructeur de [TrancheImpot], qui attend des valeurs non nulles. Sans le test, il aurait refusé de compiler. L'[ImpotException] lancée par [lireTranche] n'est pas une [SQLException] : elle traverse sans être interceptée le [catch] du bloc [init], et les trois [use] ferment néanmoins le résultat, l'ordre et la connexion.

On remarquera que le nom de la classe est le seul endroit où apparaît MySQL : le code n'utilise que les interfaces de [java.sql]. Nous y reviendrons.

10.11.4. Les tests de la couche [dao]

Les tests de la couche [dao] sont dans le dossier [src/test/kotlin] du sous-projet :

package impots.dao

import impots.entites.ImpotException
import impots.metier.IImpotMetier
import impots.metier.ImpotMetier
import java.sql.SQLException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

// tests de la couche [dao] MySqlImpot (la base [dbimpots] doit exister)
class MySqlImpotTests {

    // la base de test
    private val url = "jdbc:mysql://localhost:3306/dbimpots"
    private val utilisateur = "admimpots"
    private val motDePasse = "mdpimpots"

    // les tranches lues dans la base sont celles de la version « en dur »
    @Test
    fun `mêmes tranches que la version en dur`() {
        val reference = HardwiredImpot().tranchesImpot
        val tranches = MySqlImpot(url, utilisateur, motDePasse).tranchesImpot
        // equals compare aussi l'échelle des BigDecimal : 4962.00 (base) n'est pas égal à 4962 (en dur)
        assertNotEquals(reference, tranches)
        // compareTo ne compare que les valeurs
        assertEquals(reference.size, tranches.size)
        reference.zip(tranches).forEach { (attendue, lue) ->
            assertEquals(0, attendue.limite.compareTo(lue.limite))
            assertEquals(0, attendue.coeffR.compareTo(lue.coeffR))
            assertEquals(0, attendue.coeffN.compareTo(lue.coeffN))
        }
    }

    // la couche [metier] de la version 5 avec la nouvelle couche [dao]
    @ParameterizedTest
    @CsvSource(
        "true, 2, 60000, 4282",
        "true, 3, 60000, 2959",
        "false, 0, 60000, 11519",
    )
    fun `calcul de l'impôt`(marie: Boolean, nbEnfants: Int, salaire: Int, impot: Int) {
        val metier: IImpotMetier = ImpotMetier(MySqlImpot(url, utilisateur, motDePasse))
        assertEquals(impot, metier.calculerImpot(marie, nbEnfants, salaire))
    }

    // base inexistante (ou interdite à l'utilisateur)
    @Test
    fun `base inexistante`() {
        val e = assertFailsWith<ImpotException> {
            MySqlImpot(url.replace("dbimpots", "dbimpotsinconnue"), utilisateur, motDePasse)
        }
        assertEquals(MySqlImpot.ACCES, e.code)
        // assertIs accepte une classe dérivée
        assertIs<SQLException>(e.cause)
    }

    // mauvais mot de passe
    @Test
    fun `mot de passe incorrect`() {
        val e = assertFailsWith<ImpotException> { MySqlImpot(url, utilisateur, "x") }
        assertEquals(MySqlImpot.ACCES, e.code)
    }

    // serveur injoignable
    @Test
    fun `serveur injoignable`() {
        val e = assertFailsWith<ImpotException> {
            MySqlImpot(url.replace("3306", "3399"), utilisateur, motDePasse)
        }
        assertEquals(MySqlImpot.ACCES, e.code)
    }

    // requête qui ne rend que deux colonnes
    @Test
    fun `requête incorrecte`() {
        val e = assertFailsWith<ImpotException> {
            MySqlImpot(url, utilisateur, motDePasse, "select limite, coeffr from tranches")
        }
        assertEquals(MySqlImpot.DONNEES, e.code)
    }

    // requête qui ne rend aucune ligne
    @Test
    fun `aucune tranche`() {
        val e = assertFailsWith<ImpotException> {
            MySqlImpot(url, utilisateur, motDePasse, "select limite, coeffr, coeffn from tranches where id < 0")
        }
        assertEquals(MySqlImpot.DONNEES, e.code)
    }

    // requête dont une colonne vaut NULL : argument nommé pour la requête
    @Test
    fun `valeur NULL`() {
        val e = assertFailsWith<ImpotException> {
            MySqlImpot(url, utilisateur, motDePasse, requete = "select limite, coeffr, null from tranches")
        }
        assertEquals(MySqlImpot.DONNEES, e.code)
    }
}
  • lignes 19-21 : les tests utilisent la base [dbimpots], qui doit avoir été créée par le script [dbimpots.sql]. Ce ne sont donc plus des tests unitaires au sens strict, qui testeraient une classe isolément, mais des tests d'intégration : ils vérifient le fonctionnement de la couche [dao] avec le SGBD. On utilise souvent pour eux une base H2 en mémoire, créée au début des tests, ou un SGBD lancé dans un conteneur Docker par la bibliothèque Testcontainers ; nous nous contentons ici de la base MySQL de la machine. JUnit crée une instance de la classe de tests pour chaque test : les trois propriétés pourraient aussi être des constantes d'un [companion object] ;
  • lignes 25-35 : les tranches lues dans la base sont celles de la version « en dur » [HardwiredImpot]… mais pas au sens de [equals] (ligne 29) ! La méthode [equals] générée pour une classe de données compare ses propriétés avec leur méthode [equals], et celle de [BigDecimal] compare la valeur et l'échelle (le nombre de décimales) : les [BigDecimal] lus dans la base ont deux décimales ([4962.00]), comme la colonne [DECIMAL(10,2)], alors que ceux de [HardwiredImpot] n'en ont pas ([4962]). [compareTo], elle, ne compare que les valeurs. C'est un piège classique de [BigDecimal] (chapitre 3), que l'opérateur [==] de Kotlin n'évite pas, puisqu'il appelle [equals]. La fonction [zip] (ligne 32) associe les éléments de même rang des deux listes en paires, déstructurées en [attendue] et [lue] ;
  • lignes 40-48 : la couche [metier] de la version 5 calcule avec la nouvelle couche [dao] les impôts attendus. [@CsvSource] fournit les paramètres des trois exécutions du test ; en Kotlin, les arguments d'une annotation de type tableau s'écrivent simplement à la suite ([value] est un [vararg]) ;
  • lignes 53-59 : une base inexistante provoque une [ImpotException] de code [ACCES]. [assertFailsWith<ImpotException> { … }] de [kotlin.test] exécute le bloc, vérifie qu'il lance une exception de ce type et la rend. [assertIs<SQLException>(e.cause)] vérifie que la cause est une [SQLException] (ou d'une classe dérivée) ;
  • lignes 64-66 : de même pour un mot de passe incorrect ;
  • lignes 71-75 : un serveur injoignable (aucun serveur n'écoute sur le port 3399) provoque aussi une erreur d'accès ;
  • tests [requête incorrecte] (ligne 80), [aucune tranche] (ligne 89) et [valeur NULL] (ligne 98) : une requête qui ne rend que deux colonnes, qui ne rend aucune ligne ou dont une colonne vaut [NULL] provoque une [ImpotException] de code [DONNEES]. Le dernier passe la requête par un argument nommé, [requete = "…"], ce qui rend l'appel plus lisible.

On exécute les tests par la commande suivante, tapée dans le dossier [10-jdbc] :

> gradlew :ImpotsV6:impots-dao-mysql:test
MySqlImpotTests > serveur injoignable() PASSED

MySqlImpotTests > base inexistante() PASSED

MySqlImpotTests > mot de passe incorrect() PASSED

MySqlImpotTests > valeur NULL() PASSED

MySqlImpotTests > aucune tranche() PASSED

MySqlImpotTests > requête incorrecte() PASSED

MySqlImpotTests > calcul de l'impôt(boolean, int, int, int) > [1] marie = "true", nbEnfants = "2", salaire = "60000", impot = "4282" PASSED

MySqlImpotTests > calcul de l'impôt(boolean, int, int, int) > [2] marie = "true", nbEnfants = "3", salaire = "60000", impot = "2959" PASSED

MySqlImpotTests > calcul de l'impôt(boolean, int, int, int) > [3] marie = "false", nbEnfants = "0", salaire = "60000", impot = "11519" PASSED

MySqlImpotTests > mêmes tranches que la version en dur() PASSED
(messages de Gradle omis, avant et après les comptes rendus des tests)

Gradle affiche une ligne par test exécuté (l'ordre d'exécution des tests n'est pas celui du fichier), grâce au réglage [testLogging { events("passed", "skipped", "failed") }] du fichier [build.gradle.kts]. Pour le test paramétré, chaque ligne donne le nom de la fonction suivi des types de ses paramètres tels que les voit la JVM ([(boolean, int, int, int)] : les [Boolean] et [Int] de Kotlin sont compilés en types primitifs), puis le numéro de l'exécution entre crochets et les noms et valeurs des paramètres, ces valeurs étant écrites entre guillemets, telles qu'elles figurent dans l'annotation [@CsvSource] (chapitre 6). Chaque jeu de valeurs comptant pour un test, les dix tests ont réussi.

10.11.5. L'intégration des couches

Le sous-projet [impots-ui-mysql] contient le programme qui intègre les couches. Son fichier [build.gradle.kts] est le suivant :

// ImpotsV6 : le sous-projet impots-ui-mysql (intégration des couches)
plugins {
    kotlin("jvm")
    application
}

group = "exemples.impots"
version = "6.0"

repositories {
    mavenCentral()
}

dependencies {
    // la nouvelle couche [dao] (et, par transitivité, le pilote JDBC de MySQL à l'exécution)
    implementation(project(":ImpotsV6:impots-dao-mysql"))
    // les couches [metier] et [ui] de la version 5, inchangées (construction composite ../06-architectures)
    implementation("exemples.impots:impots-metier:5.0")
    implementation("exemples.impots:impots-ui:5.0")
    // le conteneur d'injection de dépendances de Spring
    implementation("org.springframework:spring-context:7.0.9")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // gradlew -q :ImpotsV6:impots-ui-mysql:run [--args="impots-base.properties"]
    mainClass = "impots.ui.mysql.MainKt"
}

tasks.named<JavaExec>("run") {
    // le programme lit le clavier
    standardInput = System.`in`
}

// sous Windows, les sorties écran du programme lancé par Gradle (run, bootRun...) sont encodées en UTF-8,
// sinon les caractères accentués sont abîmés dans la console d'IntelliJ IDEA ou le terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lignes 2-4 : le greffon Kotlin et le greffon [application], qui fournit la tâche [run]. Le greffon [kotlin("plugin.spring")] n'est pas nécessaire : le programme n'a pas de classe annotée par Spring ([@Configuration]…), il enregistre les couches par programme ;
  • ligne 16 : la nouvelle couche [dao], un sous-projet de la même construction, désigné par son chemin. Par transitivité, le pilote JDBC de MySQL, dépendance [runtimeOnly] de ce sous-projet, fait partie des bibliothèques de l'application à l'exécution ;
  • lignes 18-19 : les couches [metier] et [ui] de la version 5, désignées par leurs coordonnées et fournies par la construction composite. Le sous-projet [impots-ui] contient les programmes principaux de la version 5, mais nous n'utilisons que son objet [Dialogue] ;
  • ligne 21 : le conteneur d'injection de dépendances de Spring, comme dans la version 5 ;
  • lignes 32-34 : le programme exécuté par [gradlew run]. Le fichier [Main.kt] du paquetage [impots.ui.mysql] produit la classe [impots.ui.mysql.MainKt] ;
  • lignes 37-39 : le programme lit le clavier ;
  • lignes 42-45 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).

Le fichier de configuration [impots.properties] est le suivant :

1
2
3
4
5
6
# configuration de l'application [Impots], version 6 : la couche [dao] lit une base de données
dao.implementation=MySqlImpot
dao.url=jdbc:mysql://localhost:3306/dbimpots
dao.utilisateur=admimpots
dao.motdepasse=mdpimpots
dao.requete=select limite, coeffr, coeffn from tranches order by id
  • ligne 2 : l'implémentation de la couche [dao] ;
  • lignes 3-5 : l'URL JDBC de la base [dbimpots] et les identifiants de l'utilisateur [admimpots] ;
  • ligne 6 : la requête qui rend les tranches. La placer dans la configuration permet d'utiliser une autre base, dont la table ou les colonnes portent d'autres noms, sans modifier le code.

Le programme [Main.kt] est celui de la version 5, à l'enregistrement de la couche [dao] près :

package impots.ui.mysql

import impots.dao.HardwiredImpot
import impots.dao.IImpotDao
import impots.dao.MySqlImpot
import impots.entites.ImpotException
import impots.metier.IImpotMetier
import impots.metier.ImpotMetier
import impots.ui.Dialogue
import java.util.Properties
import kotlin.io.path.Path
import kotlin.io.path.bufferedReader
import kotlin.system.exitProcess
import org.springframework.beans.factory.getBean
import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.support.registerBean

// l'application [Impots], version 6 : la couche [dao] lit les tranches d'impôt dans une base de données
fun main(args: Array<String>) {
    // le fichier de configuration : impots.properties par défaut, ou celui passé en argument
    val fichierConfig = args.firstOrNull() ?: "impots.properties"

    // intégration des couches
    val metier: IImpotMetier = try {
        // lecture de la configuration, dans le dossier courant
        val config = Properties()
        Path(fichierConfig).bufferedReader().use { config.load(it) }
        val implementation = config.getProperty("dao.implementation", "MySqlImpot")
        val url = config.getProperty("dao.url")
            ?: throw ImpotException("Le paramètre [dao.url] est absent de la configuration")
        val utilisateur = config.getProperty("dao.utilisateur", "")
        val motDePasse = config.getProperty("dao.motdepasse", "")
        val requete = config.getProperty("dao.requete", MySqlImpot.REQUETE_PAR_DEFAUT)

        // le conteneur Spring : on y enregistre les couches
        val contexte = GenericApplicationContext()
        // la couche [dao] : l'implémentation est choisie par la configuration
        contexte.registerBean<IImpotDao> {
            when (implementation) {
                "MySqlImpot" -> MySqlImpot(url, utilisateur, motDePasse, requete)
                "HardwiredImpot" -> HardwiredImpot()
                else -> throw ImpotException("Implémentation [$implementation] de la couche [dao] inconnue")
            }
        }
        // la couche [metier] de la version 5 : le conteneur passera la couche [dao] à son constructeur
        contexte.registerBean<ImpotMetier>()

        // démarrage du conteneur : il crée la couche [dao], puis la couche [metier]
        contexte.refresh()
        contexte.getBean<IImpotMetier>()
    } catch (e: Exception) {
        // affichage de l'erreur et arrêt du programme
        Dialogue.afficherErreur(e)
        exitProcess(1)
    }

    // le dialogue avec l'utilisateur : la couche [ui] de la version 5
    Dialogue.run(metier)
}
  • ligne 1 : le programme est dans un paquetage propre à la version 6, [impots.ui.mysql], pour ne pas être confondu avec le programme [impots.ui.MainKt] de la version 5, présent lui aussi dans le classpath ;
  • ligne 21 : le fichier de configuration, [impots.properties] par défaut, ou celui passé en argument : [firstOrNull] rend [null] si le tableau des arguments est vide, et l'opérateur Elvis donne alors le nom par défaut ;
  • ligne 24 : comme dans la version 5, le [try] est une expression : sa valeur est celle de la dernière expression du bloc, la couche [metier] obtenue du conteneur (ligne 50). Le bloc [catch] ne rend pas de valeur : [exitProcess] est de type [Nothing], elle ne rend jamais la main ;
  • lignes 26-27 : la lecture du fichier de propriétés, dans le dossier courant ;
  • lignes 28-33 : les paramètres de la couche [dao]. [getProperty] est une méthode Java qui rend [null] pour une propriété absente : son type pour Kotlin est [String!]. Pour l'URL (lignes 29-30), l'opérateur Elvis suivi d'un [throw] fait de l'absence une erreur, et [url] est de type [String] non nullable. Les autres paramètres ont une valeur par défaut, celle de la requête étant la constante [REQUETE_PAR_DEFAUT] de [MySqlImpot] ;
  • lignes 38-42 : la couche [dao] est enregistrée dans le conteneur Spring sous l'interface [IImpotDao], avec une lambda qui la crée selon la configuration : un [MySqlImpot] (ligne 40) ou, pour comparaison, la version « en dur » de la version 5 (ligne 41). [registerBean] est ici la fonction d'extension Kotlin de Spring, à paramètre de type réifié, utilisée au chapitre 6. Les implémentations [FileImpot] et [JsonImpot] de la version 5 pourraient être ajoutées de la même façon : elles sont dans le classpath ;
  • ligne 46 : la couche [metier] de la version 5 ;
  • ligne 49 : le démarrage du conteneur crée la couche [dao], et donc lit la base, puis la couche [metier] ;
  • lignes 51-54 : une erreur de configuration ou d'accès à la base est affichée par la fonction [afficherErreur] de l'objet [Dialogue] de la version 5, et le programme s'arrête ;
  • ligne 58 : le dialogue de la version 5, inchangé.

Pour exécuter l'application, on lance la tâche [run] du sous-projet [impots-ui-mysql] depuis le dossier [10-jdbc] ; Gradle compile au passage, si nécessaire, la version 5 et la nouvelle couche [dao]. La tâche [run] exécute le programme dans le dossier du sous-projet, qui contient les fichiers de configuration. Exécution avec le fichier [impots.properties] (les saisies sont réinsérées après les questions, comme aux chapitres précédents) :

1
2
3
4
5
6
7
8
> gradlew -q :ImpotsV6:impots-ui-mysql:run
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :

On retrouve les valeurs de contrôle du cours : 4282 euros pour un couple avec deux enfants, 2959 euros avec trois enfants, 11519 euros pour une personne seule sans enfant, avec un salaire annuel de 60 000 euros.

Avec le fichier [impots-base.properties], dont l'URL désigne une base [dbimpotsinconnue] (l'option [--args] de la tâche [run] transmet les arguments du programme) :

> gradlew -q :ImpotsV6:impots-ui-mysql:run --args="impots-base.properties"
oct. 04, 2026 12:49:32 PM org.springframework.context.support.GenericApplicationContext refresh
AVERTISSEMENT: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
Chaîne des exceptions :
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
----------------------------------------
impots.entites.ImpotException: Erreur d'accès à la base des tranches d'impôt
----------------------------------------
java.sql.SQLSyntaxErrorException: Access denied for user 'admimpots'@'localhost' to database 'dbimpotsinconnue'
----------------------------------------
  • lignes 2-3 : un message de journalisation de Spring, écrit sur la sortie d'erreur : le conteneur signale que la création de la couche [dao] a échoué. C'était déjà le cas dans la version 5 ;
  • lignes 4-11 : la chaîne des exceptions affichée par [Dialogue.afficherErreur] : le conteneur Spring a encapsulé dans une [BeanCreationException] l'[ImpotException] lancée par la couche [dao], qui encapsule elle-même la [SQLException] du pilote. L'utilisateur [admimpots] n'a de droits que sur la base [dbimpots] : MySQL refuse l'accès à toute autre base, qu'elle existe ou non (ligne 10).

Le programme s'arrête avec le code 1 ([exitProcess(1)]) : Gradle considère alors que la tâche [run] a échoué et ajoute ses propres messages, non reproduits ici.

Avec le fichier [impots-motdepasse.properties], dont le mot de passe est incorrect :

> gradlew -q :ImpotsV6:impots-ui-mysql:run --args="impots-motdepasse.properties"
oct. 04, 2026 12:49:36 PM org.springframework.context.support.GenericApplicationContext refresh
AVERTISSEMENT: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
Chaîne des exceptions :
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
----------------------------------------
impots.entites.ImpotException: Erreur d'accès à la base des tranches d'impôt
----------------------------------------
java.sql.SQLException: Access denied for user 'admimpots'@'localhost' (using password: YES)
----------------------------------------

Avec le fichier [impots-serveur.properties], dont l'URL désigne le port 3399, sur lequel n'écoute aucun serveur (c'est ce qu'on obtiendrait avec un serveur MySQL arrêté) :

> gradlew -q :ImpotsV6:impots-ui-mysql:run --args="impots-serveur.properties"
oct. 04, 2026 12:49:39 PM org.springframework.context.support.GenericApplicationContext refresh
AVERTISSEMENT: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
Chaîne des exceptions :
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
----------------------------------------
impots.entites.ImpotException: Erreur d'accès à la base des tranches d'impôt
----------------------------------------
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure

The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
----------------------------------------
com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure

The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
----------------------------------------
java.net.ConnectException: Connection refused (connect failed)
----------------------------------------

La chaîne des exceptions montre ici que l'exception du pilote encapsule elle-même d'autres exceptions, jusqu'à la [ConnectException] du réseau : la connexion a été refusée.

Avec le fichier [impots-requete.properties], dont la requête ne demande que les colonnes [limite] et [coeffr] :

> gradlew -q :ImpotsV6:impots-ui-mysql:run --args="impots-requete.properties"
oct. 04, 2026 12:49:44 PM org.springframework.context.support.GenericApplicationContext refresh
AVERTISSEMENT: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
Chaîne des exceptions :
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
----------------------------------------
impots.entites.ImpotException: Les tranches d'impôt de la base sont incorrectes
----------------------------------------
java.sql.SQLException: Column Index out of range, 3 > 2.
----------------------------------------

Dans tous les cas, la couche [dao] a encapsulé l'exception d'origine dans une [ImpotException], affichée par la fonction [Dialogue.afficherErreur] de la version 5, et le programme s'arrête.

10.11.6. Changer de base de données

La couche [dao] [MySqlImpot] n'utilise que les interfaces de JDBC : malgré son nom, elle n'est pas propre à MySQL (un nom comme [JdbcImpot] lui conviendrait mieux). Pour utiliser une base PostgreSQL (ou SQL Server, ou Oracle), il suffirait :

  • de créer la table des tranches dans une base PostgreSQL, par exemple une table [data] de colonnes [data1], [data2], [data3], pour souligner l'intérêt de placer la requête dans la configuration ;
  • d'ajouter au fichier [build.gradle.kts] du sous-projet [impots-ui-mysql] la dépendance du pilote JDBC de PostgreSQL, [runtimeOnly("org.postgresql:postgresql:…")] (ou [com.microsoft.sqlserver:mssql-jdbc], [com.oracle.database.jdbc:ojdbc11]) ;
  • d'écrire un fichier de configuration analogue à [impots.properties], comme le fichier [impots-postgresql.properties] du sous-projet :
1
2
3
4
5
6
# la même application avec une base PostgreSQL (pilote org.postgresql:postgresql à ajouter au build.gradle.kts)
dao.implementation=MySqlImpot
dao.url=jdbc:postgresql://localhost:5432/dbimpots
dao.utilisateur=admimpots
dao.motdepasse=mdpimpots
dao.requete=select data1, data2, data3 from data order by id

Seul MySQL est installé sur la machine qui a servi à écrire ce cours : nous n'avons pas exécuté cette configuration. Les couches [metier] et [ui], la classe [MySqlImpot] et le programme [Main.kt] n'ont pas à être modifiés : seuls changent la configuration et le pilote présent dans le classpath. On notera que la requête de [MySqlImpot] n'a pas de paramètres et n'utilise que du SQL standard : c'est ce qui permet de la faire exécuter par n'importe quel SGBD. Une couche [dao] plus riche (recherches, mises à jour) rencontrerait vite les particularités du SQL de chaque SGBD vues plus haut : c'est là qu'un ORM comme Hibernate prend le relais.

10.12. Conclusion

Ce chapitre a présenté l'accès aux bases de données avec JDBC, depuis Kotlin :

  • JDBC est un ensemble d'interfaces ([Connection], [Statement], [PreparedStatement], [ResultSet], [DataSource]…) des paquetages [java.sql] et [javax.sql], implémentées par un pilote propre à chaque SGBD (MySQL Connector/J pour MySQL), que [DriverManager] choisit d'après l'URL JDBC de la base. Kotlin n'a pas d'API propre : il utilise JDBC directement, avec ses accesseurs vus comme des propriétés ([connexion.autoCommit = false]) ;
  • une connexion est ouverte le plus tard possible et fermée le plus tôt possible, par la fonction [use] ; un pool de connexions comme HikariCP, au travers de l'interface [DataSource], rend cette pratique peu coûteuse ;
  • [executeQuery] exécute un [select] et rend un [ResultSet] qu'on parcourt ligne à ligne avec [next], en lisant les colonnes, numérotées à partir de 1, par les méthodes [getXxx] ; des fonctions d'extension ([toList], [asSequence], [toArticle]) transforment les lignes en objets Kotlin. [executeUpdate] exécute un [insert], [update] ou [delete] et rend le nombre de lignes modifiées ;
  • les erreurs sont des [SQLException], qui donnent un code d'erreur propre au SGBD et un code SQLSTATE normalisé. Elles sont contrôlées en Java, pas en Kotlin, qui n'oblige à rien : c'est au développeur de décider où les intercepter, typiquement dans la couche [dao] qui les encapsule dans ses propres exceptions ;
  • les valeurs extérieures sont toujours passées par les paramètres d'un [PreparedStatement], jamais insérées dans le texte de la requête, ni par concaténation ni par un gabarit de chaîne, ce qui protège de l'injection SQL ;
  • les transactions ([autoCommit = false], [commit], [rollback], points de sauvegarde) rendent atomique une suite d'ordres SQL ; une fonction d'ordre supérieur comme notre [connexion.transaction { … }] en cache la mécanique ;
  • JDBC est bloquant : les threads virtuels permettent de servir de nombreux utilisateurs avec un code JDBC ordinaire, dans la limite de la taille du pool ; les coroutines l'intègrent en enveloppant les appels dans [withContext(Dispatchers.IO)], de préférence avec un parallélisme limité à la taille du pool. Les lots ([addBatch], [executeBatch]) réduisent le nombre d'échanges avec le serveur ;
  • le mode déconnecté ([CachedRowSet]) existe, mais le travail sur des objets en mémoire est aujourd'hui le domaine des ORM.

La version 6 de l'application [Impots] a montré que, grâce à l'architecture en couches, passer d'un fichier à une base de données ne demande que d'écrire une nouvelle couche [dao] et de modifier la configuration ; la construction composite de Gradle a permis de réutiliser la version 5 sans la copier ni l'installer. Le chapitre suivant écrira une septième version de cette couche avec Hibernate, qui génère lui-même les ordres SQL.

Entre le JDBC « brut » de ce chapitre et un ORM complet comme Hibernate, l'écosystème Kotlin et Java offre des bibliothèques intermédiaires, qui gardent le contrôle du SQL en supprimant le code répétitif :

  • Exposed, la bibliothèque d'accès aux bases de données de JetBrains, écrite en Kotlin : les tables sont décrites par des objets Kotlin, et les requêtes s'écrivent dans un langage dédié (DSL) typé, [Articles.selectAll().where { Articles.prix less 200 }], dans des blocs [transaction { … }] ; elle propose aussi une couche d'objets (DAO) plus proche d'un ORM ;
  • jOOQ, qui génère à partir du schéma de la base des classes Java utilisables depuis Kotlin, et permet d'écrire des requêtes SQL vérifiées à la compilation ;
  • Spring JDBC, dont l'interface [JdbcClient] (Spring 6.1) exécute une requête paramétrée et transforme ses lignes en objets en une seule instruction fluide, en gérant connexions, fermetures et exceptions.

Pour aller plus loin :