6. DuckDB depuis Java
Sections
Réglages de lecture
Voix naturelle enregistrée pour ce cours, la même dans tous les navigateurs.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Le même programme que dans la leçon 5, en Java, avec le pilote JDBC de DuckDB : java/src/main/java/ci/CiQueries.java, comparé à java/expected.txt sur les trois systèmes d’exploitation. Ce sont les différences avec C# qui sont intéressantes.
Le projet
Section intitulée « Le projet »Extrait de java/pom.xml, lignes 20-26 :
<dependencies> <dependency> <groupId>org.duckdb</groupId> <artifactId>duckdb_jdbc</artifactId> <version>1.5.5.1</version> </dependency> </dependencies>Un seul artefact, org.duckdb:duckdb_jdbc, versionné d’après DuckDB avec un quatrième numéro pour les versions du pilote : 1.5.5.1 embarque DuckDB 1.5.5. Le jar fait 85 Mo, parce qu’il contient la bibliothèque native de quatre plateformes :
60780968 2026-08-03 22:27 libduckdb_java.so_linux_amd64108682352 2026-08-03 22:28 libduckdb_java.so_osx_universal 35090432 2026-08-03 22:28 libduckdb_java.so_windows_amd64 53584160 2026-08-03 22:28 libduckdb_java.so_linux_arm64Là où DuckDB.NET répartit les plateformes dans des dossiers runtime NuGet, le pilote JDBC livre un seul jar pour toutes, et choisit le bon fichier à l’exécution. Il n’y a pas de build Windows on Arm dans cette liste.
Le programme se lance avec l’Exec Maven Plugin, depuis code/duckdb pour que les chemins data/… se résolvent, comme en C# :
mvn -q -f java/pom.xml compile exec:java -Dexec.args=checkUne requête avec un jeu de résultats
Section intitulée « Une requête avec un jeu de résultats »Extrait de java/src/main/java/ci/CiQueries.java, ligne 21 :
// Une base de données en mémoire, comme la CLI lancée sans nom de fichier try (Connection connection = DriverManager.getConnection("jdbc:duckdb:")) {Extrait de java/src/main/java/ci/CiQueries.java, lignes 33-44 :
try (var statement = connection.createStatement(); var rs = statement.executeQuery(""" SELECT workflowName, count(*) AS runs, count(*) FILTER (conclusion = 'failure') AS failures FROM 'data/runs.json' GROUP BY ALL ORDER BY runs DESC, workflowName LIMIT 3 """)) { while (rs.next()) { System.out.printf("%-24s %3d runs %2d failures%n", rs.getString(1), rs.getLong("runs"), rs.getLong("failures")); } }DuckDB v1.5.5
== 1. A query with a result setDeploy to GitHub Pages 65 runs 2 failuresRust course examples 18 runs 3 failuresJava course examples 7 runs 0 failuresjdbc:duckdb: sans rien après les deux-points désigne une base en mémoire ; jdbc:duckdb:ci.duckdb ouvre un fichier. La sortie est la même que celle du programme C#, caractère pour caractère, au titre de section près.
Paramètres
Section intitulée « Paramètres »Extrait de java/src/main/java/ci/CiQueries.java, lignes 47-56 :
try (var ps = connection.prepareStatement( "SELECT count(*) FROM read_json(?) WHERE workflowName = ? AND conclusion = ?")) { ps.setString(1, "data/runs.json"); ps.setString(2, "Rust course examples"); ps.setString(3, "failure"); try (var rs = ps.executeQuery()) { rs.next(); System.out.println("Rust course failures: " + rs.getLong(1)); } }== 2. ParametersRust course failures: 3Les paramètres ? de JDBC, numérotés à partir de 1. Ici aussi, le chemin du fichier est un paramètre.
Les types DuckDB en types Java
Section intitulée « Les types DuckDB en types Java »La même requête qu’en C# :
== 3. DuckDB types as Java typesworkflowName VARCHAR java.lang.StringdatabaseId BIGINT java.lang.LongcreatedAt TIMESTAMP java.sql.Timestampday DATE java.time.LocalDatetook INTERVAL java.lang.Stringcreated_utc TIMESTAMP WITH TIME ZONE java.time.OffsetDateTimetotal HUGEINT java.math.BigIntegerpair VARCHAR[] org.duckdb.DuckDBArrayinfo STRUCT("event" VARCHAR, attempt BIGINT) org.duckdb.DuckDBStructtook: 00:00:39created_utc as an instant: 2026-09-13T15:53:16Zcreated_utc in UTC: 2026-09-13T15:53:16ZCôte à côte avec la leçon 5 :
| DuckDB | C# (DuckDB.NET) | Java (JDBC) |
|---|---|---|
TIMESTAMP |
DateTime |
java.sql.Timestamp, ou LocalDateTime avec getObject(i, LocalDateTime.class) |
DATE |
DateOnly |
LocalDate |
INTERVAL |
TimeSpan |
String |
TIMESTAMPTZ |
DateTime, valeur UTC, Kind Unspecified |
OffsetDateTime dans le fuseau horaire de la JVM |
HUGEINT |
BigInteger |
BigInteger |
LIST |
List<T> |
java.sql.Array |
STRUCT |
Dictionary<string, object> ou une classe |
java.sql.Struct (DuckDBStruct) |
INTERVALarrive sous la forme du texte00:00:39. Pour calculer avec, convertis-le en SQL (epoch(took)donne des secondes enDOUBLE) plutôt que d’analyser la chaîne.TIMESTAMP WITH TIME ZONEdevient unOffsetDateTimeexprimé dans le fuseau par défaut de la JVM. Le même instant affiché sur cette machine avecgetObject(i).toString():2026-09-13T11:53:16-04:00; avec-Duser.timezone=UTC:2026-09-13T15:53:16Z. Ni leSET TimeZonede DuckDB ni une propriété de connexionTimeZonen’ont rien changé à la valeur Java. C’est pourquoi le programme affichetoInstant()etwithOffsetSameInstant(ZoneOffset.UTC), qui ne dépendent pas de l’endroit où il s’exécute.
Tableaux et structures
Section intitulée « Tableaux et structures »Extrait de java/src/main/java/ci/CiQueries.java, lignes 80-91 :
try (var statement = connection.createStatement(); var rs = statement.executeQuery("SELECT labels, steps FROM 'data/jobs.json' WHERE id = 104004920113")) { rs.next(); Array labels = rs.getArray("labels"); System.out.println("labels: " + Arrays.toString((Object[]) labels.getArray())); Object[] steps = (Object[]) rs.getArray("steps").getArray(); for (int i = 0; i < 3; i++) { Map<String, Object> step = ((DuckDBStruct) steps[i]).getMap(); System.out.println("step " + step.get("number") + ": " + step.get("name") + ", " + step.get("conclusion") + ", started_at is a " + step.get("started_at").getClass().getName()); } }== 4. Lists and structslabels: [ubuntu-latest]step 1: Set up job, success, started_at is a java.sql.Timestampstep 2: Checkout, success, started_at is a java.sql.Timestampstep 3: Install, build, and upload site, success, started_at is a java.sql.TimestampDans ce domaine, JDBC date d’avant les génériques : Array.getArray() renvoie un Object à caster, et une structure est un java.sql.Struct dont la méthode standard getAttributes() ne donne les valeurs que par position. Le DuckDBStruct.getMap() du pilote ajoute les noms, au prix d’un cast vers une classe du pilote. Il n’y a pas de mapping vers un record ou une classe : c’est le rôle d’une bibliothèque comme le JdbcTemplate de Spring ou jOOQ, pas essayées dans ce cours.
Erreurs : un statement qui se ferme tout seul
Section intitulée « Erreurs : un statement qui se ferme tout seul »Extrait de java/src/main/java/ci/CiQueries.java, lignes 94-109 :
try (var statement = connection.createStatement()) { try { statement.executeQuery("SELECT workflow_name FROM 'data/runs.json'"); } catch (SQLException e) { String[] lines = e.getMessage().split("\n"); System.out.println(e.getClass().getName() + ", SQLState = " + e.getSQLState()); System.out.println(lines[0]); System.out.println(lines[1]); } System.out.println("statement closed after the error: " + statement.isClosed()); try { statement.executeQuery("SELECT 42"); } catch (SQLException e) { System.out.println("next query on the same statement: " + e.getMessage()); } }== 5. Errorsjava.sql.SQLException, SQLState = nullInvalid Input Error: Attempting to execute an unsuccessful or closed pending query resultError: Binder Error: Referenced column "workflow_name" not found in FROM clause!statement closed after the error: truenext query on the same statement: Statement was closedTrois différences avec C# :
- Le message commence par une ligne générique,
Attempting to execute an unsuccessful or closed pending query result, et l’erreur réelle arrive sur la deuxième ligne, aprèsError:. Un log qui ne garde que la première ligne du message ne garde rien d’utile. SQLStatevautnull: du code qui vérifie les états SQL, comme une nouvelle tentative sur40001, n’obtient rien de ce pilote.- Le statement est fermé après l’erreur. En C#, le même
DuckDBCommandexécutait la requête suivante. En Java, une boucle qui réutilise un seulStatementet intercepte les erreurs pour continuer échoue sur chaque requête après la première erreur, avecStatement was closed. Crée un statement par requête, ce quetry-with-resources rend naturel.
Accès natif sur JDK 25
Section intitulée « Accès natif sur JDK 25 »Via Maven, le programme n’affiche rien d’autre que sa sortie. Lancé directement avec java, il commence par quatre avertissements :
java -cp "java/target/classes;$env:USERPROFILE\.m2\repository\org\duckdb\duckdb_jdbc\1.5.5.1\duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkjava -cp "java/target/classes:$HOME/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkÀ vérifier : exécuté sous Windows seulement ; le séparateur du classpath est : hors de Windows.
java -cp "java/target/classes:$HOME/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkÀ vérifier : exécuté sous Windows seulement ; le séparateur du classpath est : hors de Windows.
WARNING: A restricted method in java.lang.System has been calledWARNING: java.lang.System::load has been called by org.duckdb.DuckDBNative in an unnamed module (file:/C:/Users/spare/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar)WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this moduleWARNING: Restricted methods will be blocked in a future release unless native access is enabledCharger une bibliothèque native est une « méthode restreinte » depuis la JEP 472 : un avertissement aujourd’hui, une erreur dans un futur JDK. java --enable-native-access=ALL-UNNAMED -cp … supprime les avertissements. Maven ne les affichait pas parce que son script de lancement passe déjà cette option à la JVM dans laquelle il tourne (bin/mvn, Maven 3.9.16), et exec:java exécute le programme dans cette JVM. Une application packagée doit ajouter l’option à sa propre commande de lancement.
Chargement en masse
Section intitulée « Chargement en masse »Le mode timings charge un million de lignes avec l’appender du pilote, puis 10 000 lignes avec un batch et avec des instructions INSERT individuelles, dans une transaction (lignes 120-127) :
try (var appender = connection.unwrap(DuckDBConnection.class).createAppender(DuckDBConnection.DEFAULT_SCHEMA, "numbers")) { for (int i = 0; i < appended; i++) { appender.beginRow(); appender.append(i); appender.append("n" + i); appender.endRow(); } }unwrap est la façon JDBC d’atteindre la classe propre d’un pilote à partir d’une Connection. Les temps de la CI, avec ceux de C# de la leçon 5 pour comparaison :
| Runner | Appender Java, 1 000 000 lignes | Batch Java, 10 000 lignes | Java une par une, 10 000 lignes | INSERT C#, 10 000 lignes |
|---|---|---|---|---|
ubuntu-latest |
257 ms | 858 ms | 832 ms | 3 165 ms |
windows-latest |
511 ms | 2 443 ms | 2 378 ms | 3 917 ms |
macos-latest |
212 ms | 690 ms | 675 ms | 1 312 ms |
- L’appender charge un million de lignes en à peu près le temps que met C#, sauf sous Windows.
addBatchetexecuteBatchn’apportent rien : le batch n’a été plus rapide que les instructions individuelles sur aucun runner, ni sur cette machine (609 ms contre 532 ms). Avec ce pilote, un batch JDBC n’envoie pas les lignes en un seul appel. Utilise l’appender, ouINSERT … SELECTdepuis un fichier.- Les instructions
INSERTindividuelles sont 1,6 à 3,8 fois plus rapides en Java qu’en C#. Pas étudié : les deux préparent l’instruction une seule fois ; la différence tient peut-être à la façon dont chaque wrapper lie les paramètres.
À retenir
Section intitulée « À retenir »- Un seul jar,
org.duckdb:duckdb_jdbc, contient le pilote et les bibliothèques natives de quatre plateformes ; l’URL JDBCjdbc:duckdb:ouvre une base en mémoire. INTERVALest unString, etTIMESTAMPTZunOffsetDateTimedans le fuseau de la JVM : convertis enInstantavant d’afficher ou de comparer.- Les tableaux et les structures passent par
java.sql.ArrayetDuckDBStruct.getMap(), avec des casts. - Après une erreur, le statement est fermé : un statement par requête.
- JDK 25 avertit à propos de l’accès natif, sauf si la JVM est lancée avec
--enable-native-access=ALL-UNNAMED. - Les batchs JDBC n’accélèrent pas les insertions avec ce pilote ; l’appender, si.
Exercices
Section intitulée « Exercices »- Un service exécute
SELECT createdAt AT TIME ZONE 'UTC' FROM runset renvoiers.getObject(1).toString()dans une réponse JSON. Il donne2026-09-13T15:53:16Zen production et2026-09-13T11:53:16-04:00sur le portable d’un développeur. L’un des deux est-il faux ? Comment les mettre d’accord ?
Solution
Aucun n’est faux : les deux sont le même instant, avec des décalages différents. La valeur dépend du fuseau horaire par défaut de la JVM (user.timezone, ou le réglage de l’OS), comme la leçon l’a mesuré. Pour rendre la sortie indépendante de la machine, choisis la représentation dans le code : rs.getObject(1, OffsetDateTime.class).toInstant() affiche 2026-09-13T15:53:16Z partout, tout comme withOffsetSameInstant(ZoneOffset.UTC). Lancer la JVM avec -Duser.timezone=UTC fonctionne aussi, mais cela change toutes les dates de l’application, et c’est facile à oublier sur une machine.
- Cette boucle insère des lignes et saute les lignes invalides. Avec le pilote DuckDB, que se passe-t-il après la première ligne invalide ?
try (var statement = connection.createStatement()) { for (String sql : statements) { try { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); } }}Solution
Chaque instruction suivante échoue avec Statement was closed, et la boucle « saute » toutes les lignes restantes, valides ou non : la section 5 du programme montre le statement fermé après une requête en échec. Crée le statement dans la boucle :
for (String sql : statements) { try (var statement = connection.createStatement()) { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); }}Vérifié dans un programme jetable avec trois instructions INSERT, la deuxième insérant 'x' dans une colonne INTEGER :
skipped: Invalid Input Error: Attempting to execute an unsuccessful or closed pending query resultskipped: Statement was closedrows: 1La troisième ligne, valide, n’a jamais été insérée.
- Le programme C# formate les dates avec
CultureInfo.InvariantCulture. Le programme Java a-t-il le même problème avecprintf("%,d")dans son modetimings, et cela compte-t-il pour la CI ?
Solution
Oui : %,d utilise le séparateur de groupes de la locale par défaut. La CI a affiché 1,000,000 ; dans un programme jetable, String.format(Locale.FRANCE, "%,d", 1_000_000) séparait les groupes avec U+202F, une espace fine insécable, qui ressemble à une espace et n’en est pas une pour un parseur. Cela ne compte pas pour la CI, parce que check.sh ne compare que la sortie du mode check, et les temps ne sont pas comparés. Pour une sortie comparée ou analysée, utilise String.format(Locale.ROOT, …), qui a donné 1,000,000.