5. DuckDB depuis C#
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.
La CLI exécute DuckDB dans son propre processus. Depuis C#, DuckDB tourne dans ton processus, comme une bibliothèque native appelée via DuckDB.NET, un fournisseur ADO.NET : DbConnection, DbCommand, DbDataReader, les classes que tu utilises déjà avec SQL Server ou SQLite. Le programme de cette leçon est csharp/CiQueries/Program.cs, lancé depuis code/duckdb et comparé à csharp/expected.txt sur les trois systèmes d’exploitation.
Le projet
Section intitulée « Le projet »Extrait de csharp/CiQueries/CiQueries.csproj, lignes 10-13 :
<ItemGroup> <PackageReference Include="Dapper" Version="2.1.86" /> <PackageReference Include="DuckDB.NET.Data.Full" Version="1.5.5" /></ItemGroup>DuckDB.NET existe en quatre paquets : le fournisseur ADO.NET (DuckDB.NET.Data) ou les bindings de bas niveau, chacun avec ou sans la bibliothèque native de DuckDB. .Full l’inclut, pour toutes les plateformes, et cela a un coût :
| Taille | |
|---|---|
duckdb.net.bindings.full 1.5.5 dans le cache NuGet |
420 Mo |
bin/Debug/net10.0 après dotnet build |
316 Mo |
dotnet publish -r linux-x64 |
69 Mo |
Un simple build copie la bibliothèque native des cinq runtimes : win-x64 (37 Mo), win-arm64 (43 Mo), linux-x64 (71 Mo), linux-arm64 (63 Mo) et osx (117 Mo, un seul fichier pour Intel et Apple Silicon). Publier pour un identificateur de runtime ne garde que la sienne. La version du paquet suit celle de DuckDB : 1.5.5 embarque DuckDB 1.5.5.
Une requête avec un data reader
Section intitulée « Une requête avec un data reader »Extrait de csharp/CiQueries/Program.cs, lignes 11-13 :
// Une base de données en mémoire, comme la CLI lancée sans nom de fichierusing var connection = new DuckDBConnection("Data Source=:memory:");connection.Open();Console.WriteLine($"DuckDB {connection.ServerVersion}");Extrait de csharp/CiQueries/Program.cs, lignes 22-36 :
using (var command = connection.CreateCommand()){ command.CommandText = """ 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 """; using var reader = command.ExecuteReader(); while (reader.Read()) { Console.WriteLine($"{reader.GetString(0),-24} {reader.GetInt64(1),3} runs {reader.GetInt64(2),2} failures"); }}DuckDB v1.5.5
== 1. A query with a data readerDeploy to GitHub Pages 65 runs 2 failuresRust course examples 18 runs 3 failuresJava course examples 7 runs 0 failuresRien de nouveau pour un utilisateur d’ADO.NET, et le SQL est celui de la leçon 1, nom de fichier compris. Data Source=file.duckdb ouvre ou crée à la place un fichier de base de données ; c’est le sujet de la leçon 8.
'data/runs.json' est relatif au répertoire de travail du processus, pas au projet ni à l’exécutable. Lancée depuis le dossier du projet avec un chemin ../data, la première version échouait :
Unhandled exception. DuckDB.NET.Data.DuckDBException (0x80004005): IO Error: No files found that match the pattern "../data/runs.json"../data depuis csharp/CiQueries, c’est csharp/data, qui n’existe pas. Une application qui lit des fichiers devrait construire des chemins absolus, à partir de sa configuration ou de AppContext.BaseDirectory.
Paramètres
Section intitulée « Paramètres »DuckDB accepte trois syntaxes : ?, $1 et $name. Avec DuckDBParameter, le nom s’écrit sans le $ (lignes 39-46) :
using (var command = connection.CreateCommand()){ command.CommandText = "SELECT count(*) FROM read_json($path) WHERE workflowName = $workflow AND conclusion = $conclusion"; command.Parameters.Add(new DuckDBParameter("path", "data/runs.json")); command.Parameters.Add(new DuckDBParameter("workflow", "Rust course examples")); command.Parameters.Add(new DuckDBParameter("conclusion", "failure")); Console.WriteLine($"Rust course failures: {command.ExecuteScalar()}");}== 2. ParametersRust course failures: 3Même le nom de fichier est un paramètre : read_json($path), là où OPENROWSET(BULK …) de SQL Server n’accepte qu’un littéral et pousse à construire la chaîne SQL. Un chemin qui vient d’un utilisateur doit quand même être vérifié avant d’atteindre read_json, car DuckDB peut lire n’importe quel fichier accessible au processus.
Les types DuckDB en types .NET
Section intitulée « Les types DuckDB en types .NET »Extrait de csharp/CiQueries/Program.cs, lignes 49-69 :
using (var command = connection.CreateCommand()){ command.CommandText = """ SELECT workflowName, databaseId, createdAt, createdAt::DATE AS day, updatedAt - startedAt AS took, createdAt AT TIME ZONE 'UTC' AS created_utc, sum(databaseId) OVER () AS total, [event, conclusion] AS pair, {'event': event, 'attempt': attempt} AS info FROM 'data/runs.json' ORDER BY createdAt LIMIT 1 """; using var reader = command.ExecuteReader(); reader.Read(); for (var i = 0; i < reader.FieldCount; i++) { Console.WriteLine($"{reader.GetName(i),-12} {reader.GetDataTypeName(i),-12} {reader.GetFieldType(i)}"); } var createdUtc = reader.GetDateTime(5); Console.WriteLine($"created_utc as DateTime: {createdUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)}, Kind = {createdUtc.Kind}"); Console.WriteLine($"created_utc as DateTimeOffset: {reader.GetFieldValue<DateTimeOffset>(5).ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture)}"); Console.WriteLine($"took: {reader.GetFieldValue<TimeSpan>(4)}");}== 3. DuckDB types as .NET typesworkflowName Varchar System.StringdatabaseId BigInt System.Int64createdAt Timestamp System.DateTimeday Date System.DateOnlytook Interval System.TimeSpancreated_utc TimestampTz System.DateTimetotal HugeInt System.Numerics.BigIntegerpair List System.Collections.Generic.List`1[System.String]info Struct System.Collections.Generic.Dictionary`2[System.String,System.Object]created_utc as DateTime: 2026-09-13 15:53:16, Kind = Unspecifiedcreated_utc as DateTimeOffset: 2026-09-13 15:53:16 +00:00took: 00:00:39DATEdevientDateOnly, etINTERVALdevientTimeSpan.sumd’unBIGINTest unHUGEINT, un entier sur 128 bits, doncSystem.Numerics.BigInteger, paslong. L’exercice 2 montre ce que cela coûte ;CAST(sum(…) AS BIGINT)dans le SQL évite la question.- Un
TIMESTAMP WITH TIME ZONErevient sous forme deDateTimecontenant l’heure UTC, mais avec unKindUnspecified, pasUtc.ToUniversalTime()traite une valeurUnspecifiedcomme une heure locale et la décale : dans un programme d’essai sur cette machine (UTC−4),15:53:16est devenu19:53:16, quatre heures d’écart.GetFieldValue<DateTimeOffset>renvoie un+00:00explicite. Le réglageTimeZonede la session, vu à la leçon 2, ne change pas ce que reçoit C# : avecSET TimeZone = 'Europe/Paris', c’est le même15:53:16qui est revenu. - Les dates sont formatées avec
CultureInfo.InvariantCulture: la première version affichait2026-09-14 2:01:09 PMsur cette machine, un format qui dépend des paramètres régionaux de l’utilisateur et ne correspondrait jamais à la sortie attendue par la CI.
Listes et structs
Section intitulée « Listes et structs »Extrait de csharp/CiQueries/Program.cs, lignes 72-88 :
using (var command = connection.CreateCommand()){ command.CommandText = "SELECT labels, steps FROM 'data/jobs.json' WHERE id = 104004920113"; using var reader = command.ExecuteReader(); reader.Read(); var labels = reader.GetFieldValue<List<string>>(0); Console.WriteLine($"labels: {string.Join(", ", labels)}");
var asDictionaries = reader.GetFieldValue<List<Dictionary<string, object?>>>(1); Console.WriteLine($"first step as a dictionary: {string.Join(", ", asDictionaries[0].Select(kv => $"{kv.Key}={Format(kv.Value)}"))}");
var steps = reader.GetFieldValue<List<Step>>(1); foreach (var step in steps.Take(3)) { Console.WriteLine($"step {step.Number}: {step.Name}, {step.Conclusion}, {(step.Completed_At - step.Started_At).TotalSeconds} s, StartedAt = {Format(step.StartedAt)}"); }}Extrait de csharp/CiQueries/Program.cs, lignes 174-182 :
// DuckDB.NET fait correspondre les champs d'une struct aux propriétés modifiables de même nom, sans tenir compte de la casseclass Step{ public long Number { get; set; } public string Name { get; set; } = ""; public string Conclusion { get; set; } = ""; public DateTime Started_At { get; set; } public DateTime Completed_At { get; set; } public DateTime StartedAt { get; set; }}== 4. Lists and structslabels: ubuntu-latestfirst step as a dictionary: number=1, name=Set up job, conclusion=success, started_at=2026-09-14 14:01:15, completed_at=2026-09-14 14:01:19step 1: Set up job, success, 4 s, StartedAt = 0001-01-01 00:00:00step 2: Checkout, success, 2 s, StartedAt = 0001-01-01 00:00:00step 3: Install, build, and upload site, success, 48 s, StartedAt = 0001-01-01 00:00:00- Une
LISTdeVARCHARse lit comme uneList<string>; uneSTRUCTcomme unDictionary<string, object>, ou comme une de tes classes. - La correspondance se fait par nom de propriété, sans tenir compte de la casse :
Started_Atcorrespond àstarted_at.StartedAt, non, et rien ne le signale : la propriété garde sa valeur par défaut,0001-01-01. En C#, c’est le nom naturel qui échoue en silence. Renomme dans le SQL (exercice 1), ou vérifie les valeurs par défaut dans un test. - Un record positionnel
record Step(long Number, …)échoue à l’exécution :MissingMethodException: Cannot dynamically create an instance of type 'Step'. Reason: No parameterless constructor defined.Le type cible a besoin d’un constructeur sans paramètre et de setters, ou d’accesseursinit.
Extrait de csharp/CiQueries/Program.cs, lignes 91-103 :
using (var command = connection.CreateCommand()){ command.CommandText = "SELECT workflow_name FROM 'data/runs.json'"; try { command.ExecuteScalar(); } catch (DuckDBException e) { Console.WriteLine($"{e.GetType().Name}, ErrorType = {e.ErrorType}"); Console.WriteLine(e.Message.Split('\n')[0]); }}== 5. ErrorsDuckDBException, ErrorType = InvalidBinder Error: Referenced column "workflow_name" not found in FROM clause!Un seul type d’exception, DuckDBException, avec le même message que la CLI, suggestions et position comprises. Son ErrorType indique Invalid, pas Binder : pour distinguer les genres d’erreurs, le préfixe du message est plus précis que la propriété. La commande reste utilisable après l’erreur : dans un programme d’essai, la même DuckDBCommand a ensuite exécuté SELECT 42 et renvoyé 42. La leçon 6 montre que le pilote Java se comporte différemment.
Dapper étend n’importe quelle DbConnection, il fonctionne donc sur DuckDBConnection sans adaptateur (lignes 106-118) :
var failures = connection.Query<RunSummary>( """ SELECT databaseId, workflowName, createdAt, updatedAt - startedAt AS took FROM 'data/runs.json' WHERE conclusion = $conclusion AND event = $event ORDER BY createdAt LIMIT 3 """, new { conclusion = "failure", @event = "push" });foreach (var run in failures){ Console.WriteLine($"{run.DatabaseId} {run.WorkflowName,-24} {Format(run.CreatedAt)} {run.Took}");}Extrait de csharp/CiQueries/Program.cs, ligne 171 :
// Dapper fait correspondre les colonnes aux paramètres du constructeur, sans tenir compte de la casserecord RunSummary(long DatabaseId, string WorkflowName, DateTime CreatedAt, TimeSpan Took);== 6. Dapper34772306529 Rust course examples 2026-09-13 17:40:34 00:00:4034772373891 Rust course examples 2026-09-13 17:41:56 00:00:3134776807003 Rust course examples 2026-09-13 19:09:08 00:01:55Les propriétés de l’objet anonyme deviennent les paramètres $conclusion et $event ; @event est la façon dont C# écrit une propriété qui porte le nom d’un mot-clé. Contrairement à la correspondance des structs de DuckDB.NET, Dapper remplit un record positionnel via son constructeur. Les trois pushes en échec sont les échecs du cours Rust de la leçon 2.
Chargement en masse : l’appender
Section intitulée « Chargement en masse : l’appender »Charger des lignes un INSERT à la fois est lent dans toutes les bases de données. DuckDB.NET expose l’appender de DuckDB, qui écrit les lignes directement dans le stockage d’une table (lignes 138-146) :
const int appended = 1_000_000; var stopwatch = Stopwatch.StartNew(); using (var appender = connection.CreateAppender("numbers")) { for (var i = 0; i < appended; i++) { appender.CreateRow().AppendValue(i).AppendValue($"n{i}").EndRow(); } }Le mode timings du programme charge un million de lignes avec l’appender, puis 10 000 lignes avec un INSERT paramétré dans une transaction. La CI affiche les temps sans les comparer :
| Runner | Appender, 1 000 000 lignes | INSERT, 10 000 lignes |
|---|---|---|
ubuntu-latest |
256 ms | 3 165 ms |
windows-latest |
331 ms | 3 917 ms |
macos-latest |
226 ms | 1 312 ms |
| Cette machine (Windows, Release) | 170 ms | 1 577 ms |
Avec 100 fois plus de lignes, l’appender reste 6 à 12 fois plus rapide : par ligne, 600 à 1 200 fois plus rapide. Les lignes sont écrites quand l’appender est libéré, d’où le using : dans un programme d’essai, count(*) sur la table valait 0 après l’ajout de 10 lignes, et 10 après Dispose(). Pour des données déjà dans un fichier, INSERT INTO … SELECT * FROM 'file.parquet' est encore plus simple et ne franchit pas la frontière entre code managé et code natif pour chaque valeur.
À retenir
Section intitulée « À retenir »- DuckDB.NET est un fournisseur ADO.NET :
DuckDBConnection, les commandes, les readers, les transactions et Dapper fonctionnent comme avec n’importe quelle autre base de données. - Le paquet
.Fullembarque la bibliothèque native de toutes les plateformes ; publie pour un identificateur de runtime pour n’en livrer qu’une. - Les chemins de fichiers relatifs dans le SQL sont relatifs au répertoire de travail du processus.
DATE→DateOnly,INTERVAL→TimeSpan,HUGEINT→BigInteger,TIMESTAMPTZ→DateTimeavec unKindUnspecified.- Les structs correspondent aux classes par nom de propriété, et une propriété sans correspondance garde en silence sa valeur par défaut.
- Pour beaucoup de lignes, utilise l’appender, ou laisse DuckDB lire le fichier.
Exercices
Section intitulée « Exercices »- Modifie la classe
Steppour queStartedAtreçoive l’heure de début du step sans la renommer, en ne changeant que le SQL.
Solution
Reconstruis chaque struct avec les noms qu’attend la classe, avec list_transform de la leçon 3 :
SELECT list_transform(steps, lambda s: {'number': s.number, 'name': s.name, 'startedAt': s.started_at}) AS stepsFROM 'data/jobs.json'WHERE id = 104004920113Avec une classe Step qui n’a que Number, Name et StartedAt, le premier step donne :
1 Set up job 2026-09-14 14:01:15Le SQL devient le seul endroit où le format externe (le snake_case de l’API GitHub) rencontre le nommage C#. Cette vérification a été faite une fois dans un programme d’essai, pas dans le programme de la CI.
ExecuteScalarsurSELECT sum(databaseId) FROM 'data/runs.json'renvoie unobject. Quel est son type, et que fait(long)command.ExecuteScalar()!?
Solution
sum d’un BIGINT est un HUGEINT, lu comme System.Numerics.BigInteger (section 3 du programme). Le cast lève une exception :
InvalidCastException: Unable to cast object of type 'System.Numerics.BigInteger' to type 'System.Int64'.L’unboxing ne fonctionne que vers le type exact de la valeur boxée. La porte de sortie habituelle, Convert.ToInt64(value), échoue aussi, car BigInteger n’implémente pas IConvertible :
InvalidCastException: Unable to cast object of type 'System.Numerics.BigInteger' to type 'System.IConvertible'.Ce qui fonctionne : unboxer vers le vrai type, puis convertir, (long)(BigInteger)value, qui a donné 4351672497299 ; ou CAST(sum(databaseId) AS BIGINT) dans le SQL, pour que le reader renvoie un long. Vérifié dans un programme d’essai, pas dans le programme de la CI.
- Pourquoi la section 5 affiche-t-elle
e.Message.Split('\n')[0]plutôt que le message entier ?
Solution
Le message complet tient sur plusieurs lignes, comme dans la CLI : l’erreur, Candidate bindings: "workflowName", "conclusion", une ligne vide, puis la requête avec un ^ sous la position. La première ligne suffit pour un log et garde la sortie attendue courte. Garde le message complet là où un développeur le lit : le marqueur de position est la partie la plus utile d’une erreur de syntaxe dans une longue requête.