Appeler une procédure stockée SQL IBM i en C# (.NET) avec NTi

Introduction

Les procédures stockées permettent de centraliser et d'encapsuler la logique métier dans un environnement sécurisé et optimisé. Elles déplacent une partie de la logique applicative vers le serveur de base de données, réduisant la complexité du code côté client, améliorant les performances en diminuant le trafic réseau, et renforçant la sécurité en limitant l'accès direct aux tables.

Ce tutoriel montre comment appeler une procédure stockée SQL IBM i depuis une application C# (.NET) en utilisant NTi.

La procédure utilisée provient de la documentation officielle IBM i, la DB2 for i SQL Reference (page 1141). Elle calcule la médiane des salaires du personnel et retourne la liste des employés dont le salaire est supérieur à cette médiane.

Trois points sont couverts :

  1. Approche classique avec un DataReader
  2. Approche simplifiée avec Dapper, un micro-ORM léger
  3. Procédures à plusieurs result sets avec NextResultAsync

Étape 1 - Préparer l'environnement IBM i

Avant d'appeler la procédure depuis .NET, il faut comprendre ce qu'elle fait, quels éléments elle nécessite (tables, données), et préparer l'environnement IBM i.

Voici le code SQL de la procédure, issu du manuel DB2 for i SQL Reference (page 1141) :

CREATE PROCEDURE MEDIAN_RESULT_SET (OUT medianSalary DECIMAL(7,2))
LANGUAGE SQL
DYNAMIC RESULT SETS 1
BEGIN
    DECLARE v_numRecords INTEGER DEFAULT 1;
    DECLARE v_counter INTEGER DEFAULT 0;
    DECLARE c1 CURSOR FOR
         SELECT salary
         FROM staff
         ORDER BY salary;
    DECLARE c2 CURSOR WITH RETURN FOR
         SELECT name, job, salary
         FROM staff
         WHERE salary > medianSalary
         ORDER BY salary;
    DECLARE EXIT HANDLER FOR NOT FOUND
         SET medianSalary = 6666;
    SET medianSalary = 0;
    SELECT COUNT(*) INTO v_numRecords FROM staff;
    OPEN c1;
    WHILE v_counter < (v_numRecords / 2 + 1) DO
         FETCH c1 INTO medianSalary;
         SET v_counter = v_counter + 1;
    END WHILE;
    CLOSE c1;
    OPEN c2;
END

Cette procédure calcule le salaire médian des employés. Elle retourne ce salaire via un paramètre de sortie medianSalary, et ouvre un curseur c2 qui retourne la liste des employés dont le salaire dépasse cette médiane.

Paramètre Type Direction Description
medianSalary Decimal(7,2) OUT Salaire médian calculé par la procédure

💡 Il n'y a pas de paramètre en entrée. La procédure effectue son calcul directement à partir de la table staff.

Pour que la procédure fonctionne, une table staff doit exister dans le même schéma, avec au moins les colonnes salary (DECIMAL), name et job.

Créer la bibliothèque

Créez une bibliothèque MDSALARY pour isoler les éléments de test. CRTLIB est une commande CL, pas une instruction SQL. Dans l'Exécuteur de scripts SQL d'ACS, une commande CL se préfixe par CL: et se termine par ; comme le reste du script :

CL: CRTLIB MDSALARY;

Définir le schéma courant

Définissez le schéma courant pour que toutes les instructions SQL suivantes s'y réfèrent automatiquement :

SET CURRENT SCHEMA = MDSALARY;

Désormais, chaque table ou procédure créée sera automatiquement placée dans la bibliothèque MDSALARY.

Créer la table STAFF

Créez la table staff selon les colonnes requises :

CREATE TABLE staff (
    name VARCHAR(50),
    job VARCHAR(50),
    salary DECIMAL(7,2)
);
  • name - nom de l'employé
  • job - fonction
  • salary - salaire

Insérer un jeu de données

Insérez quelques données représentatives pour calculer un salaire médian pertinent :

INSERT INTO staff (name, job, salary) VALUES ('Alice', 'Manager', 2000.00);
INSERT INTO staff (name, job, salary) VALUES ('Bob', 'Clerk', 3000.00);
INSERT INTO staff (name, job, salary) VALUES ('Charlie', 'Analyst', 4000.00);
INSERT INTO staff (name, job, salary) VALUES ('David', 'Developer', 5000.00);
INSERT INTO staff (name, job, salary) VALUES ('Eve', 'Designer', 6000.00);
INSERT INTO staff (name, job, salary) VALUES ('Frank', 'Tester', 7000.00);

Script complet prêt à exécuter dans ACS

Copiez-collez ce script dans l'outil Exécuteur de scripts SQL d'ACS et exécutez-le en une seule fois :

-- Création de la bibliothèque (commande CL : préfixe CL: et point-virgule final)
CL: CRTLIB MDSALARY;

-- Définition du schéma courant
SET CURRENT SCHEMA = MDSALARY;

-- Création de la table STAFF
CREATE TABLE staff (
    name VARCHAR(50),
    job VARCHAR(50),
    salary DECIMAL(7,2)
);

-- Insertion des données de test
INSERT INTO staff (name, job, salary) VALUES ('Alice', 'Manager', 2000.00);
INSERT INTO staff (name, job, salary) VALUES ('Bob', 'Clerk', 3000.00);
INSERT INTO staff (name, job, salary) VALUES ('Charlie', 'Analyst', 4000.00);
INSERT INTO staff (name, job, salary) VALUES ('David', 'Developer', 5000.00);
INSERT INTO staff (name, job, salary) VALUES ('Eve', 'Designer', 6000.00);
INSERT INTO staff (name, job, salary) VALUES ('Frank', 'Tester', 7000.00);

-- Création de la procédure stockée MEDIAN_RESULT_SET
CREATE PROCEDURE MEDIAN_RESULT_SET (OUT medianSalary DECIMAL(7,2))
LANGUAGE SQL
DYNAMIC RESULT SETS 1
BEGIN
    DECLARE v_numRecords INTEGER DEFAULT 1;
    DECLARE v_counter INTEGER DEFAULT 0;
    DECLARE c1 CURSOR FOR
         SELECT salary FROM staff ORDER BY salary;
    DECLARE c2 CURSOR WITH RETURN FOR
         SELECT name, job, salary FROM staff WHERE salary > medianSalary ORDER BY salary;
    DECLARE EXIT HANDLER FOR NOT FOUND SET medianSalary = 6666;
    SET medianSalary = 0;
    SELECT COUNT(*) INTO v_numRecords FROM staff;
    OPEN c1;
    WHILE v_counter < (v_numRecords / 2 + 1) DO
         FETCH c1 INTO medianSalary;
         SET v_counter = v_counter + 1;
    END WHILE;
    CLOSE c1;
    OPEN c2;
END;

Vérifier sur l'IBM i

Vérifiez que la bibliothèque MDSALARY existe, qu'elle contient la table staff avec les données insérées, et la procédure MEDIAN_RESULT_SET (type *PGM).

Ecran 5250


Étape 2 - Appeler la procédure stockée depuis .NET

Créez un projet Blazor Web App en .NET 8 et installez les packages suivants :

dotnet add package Aumerial.Data.Nti
dotnet add package Dapper

Créer un service de connexion

Créez un service DB2Service.cs pour centraliser la gestion des connexions. Avec NTi, l'asynchrone est la voie normale. L'async est réel de bout en bout (OpenAsync jusqu'à DisposeAsync), sans blocage de thread. La voie synchrone (Open()) reste disponible pour les contextes qui l'exigent.

using System.Threading;
using System.Threading.Tasks;
using Aumerial.Data.Nti;

public class DB2Service
{
    private readonly string _connectionString =
        "server=MY_SYSTEM;user=MY_USER;password=MY_PASSWORD;pooling=true";

    public async Task CreateConnectionAsync(CancellationToken cancellationToken = default)
    {
        var conn = new NTiConnection(_connectionString);
        await conn.OpenAsync(cancellationToken);
        return conn;
    }
}

💡 Quelques défauts à connaître. Le pool de connexions est inactif par défaut (compatibilité v4), d'où le pooling=true, fortement recommandé pour une application web. Tous les timeouts sont par ailleurs illimités par défaut (0 = infini), et persist security info=false expurge le mot de passe de ConnectionString dès la connexion ouverte.

Enregistrez ensuite ce service dans Program.cs :

builder.Services.AddSingleton();

Créer l'entité Employee

public class Employee
{
    public string Name { get; set; } = "";
    public string Job { get; set; } = "";
    public decimal Salary { get; set; }
}

Injecter le service dans le composant Blazor

Point souvent oublié, le composant doit recevoir le service par la directive @inject, sans quoi Db2Service n'existe pas dans le code du composant. Les noms restent cohérents de bout en bout, avec la classe qui s'appelle DB2Service et l'instance injectée Db2Service.

Créez un composant StoredProcedure.razor :

@page "/stored-procedure"
@rendermode InteractiveServer
@using System.Data
@using Aumerial.Data.Nti
@using Dapper
@inject DB2Service Db2Service

<h3>Salaires au-dessus de la médiane</h3>

<button class="btn btn-primary" @onclick="LoadDataWithDataReader">DataReader</button>
<button class="btn btn-secondary" @onclick="LoadDataWithDapper">Dapper</button>

<p>Salaire médian : @median</p>

<table class="table">
    <thead>
        <tr><th>Nom</th><th>Poste</th><th>Salaire</th></tr>
    </thead>
    <tbody>
        @foreach (var employee in employees)
        {
            <tr><td>@employee.Name</td><td>@employee.Job</td><td>@employee.Salary</td></tr>
        }
    </tbody>
</table>

Les champs et méthodes des deux approches ci-dessous se placent dans le bloc @code du composant :

private decimal median;
private List employees = new();

Méthode 1 - Approche classique (DataReader)

Créez une connexion via Db2Service, configurez une commande NTi pour appeler MEDIAN_RESULT_SET, définissez le paramètre de sortie medianSalary, puis lisez les résultats via un DataReader :

private async Task LoadDataWithDataReader()
{
    employees.Clear();

    await using var conn = await Db2Service.CreateConnectionAsync();
    await using var cmd = new NTiCommand("MDSALARY.MEDIAN_RESULT_SET", conn);
    cmd.CommandType = CommandType.StoredProcedure;

    var param = new NTiParameter
    {
        ParameterName = "medianSalary",
        Direction = ParameterDirection.Output
    };
    cmd.Parameters.Add(param);

    await using var reader = await cmd.ExecuteReaderAsync();
    median = Convert.ToDecimal(param.Value);

    while (await reader.ReadAsync())
    {
        employees.Add(new Employee
        {
            Name = reader.GetString(0),
            Job = reader.GetString(1),
            Salary = reader.GetDecimal(2)
        });
    }
}

Méthode 2 - Approche simplifiée (Dapper)

Avec Dapper, définissez le paramètre de sortie via DynamicParameters. Dapper gère automatiquement l'exécution et mappe les résultats directement en liste d'objets Employee :

private async Task LoadDataWithDapper()
{
    await using var conn = await Db2Service.CreateConnectionAsync();

    var parameters = new DynamicParameters();
    parameters.Add("medianSalary", dbType: DbType.Decimal, direction: ParameterDirection.Output);

    employees = (await conn.QueryAsync(
        "MDSALARY.MEDIAN_RESULT_SET",
        parameters,
        commandType: CommandType.StoredProcedure)).ToList();

    median = parameters.Get("medianSalary");
}

Afficher les résultats dans un composant Blazor

Ecran Appli .NET


Étape 3 - Plusieurs result sets (DYNAMIC RESULT SETS 2)

Une procédure DB2 for i peut retourner plusieurs result sets. Il suffit de déclarer DYNAMIC RESULT SETS 2 (ou plus), et d'ouvrir plusieurs curseurs WITH RETURN. Côté .NET, on passe d'un result set au suivant avec la méthode ADO.NET standard NextResultAsync (NextResult en synchrone).

Créez une variante de la procédure qui retourne deux listes, les salaires au-dessus de la médiane, puis ceux en dessous ou égaux :

CREATE PROCEDURE MDSALARY.MEDIAN_MULTI (OUT medianSalary DECIMAL(7,2))
LANGUAGE SQL
DYNAMIC RESULT SETS 2
BEGIN
    DECLARE v_numRecords INTEGER DEFAULT 1;
    DECLARE v_counter INTEGER DEFAULT 0;
    DECLARE c1 CURSOR FOR
         SELECT salary FROM staff ORDER BY salary;

    -- Result set 1 : salaires au-dessus de la médiane
    DECLARE c2 CURSOR WITH RETURN FOR
         SELECT name, job, salary FROM staff WHERE salary > medianSalary ORDER BY salary;

    -- Result set 2 : salaires en dessous ou égaux à la médiane
    DECLARE c3 CURSOR WITH RETURN FOR
         SELECT name, job, salary FROM staff WHERE salary <= medianSalary ORDER BY salary;

    DECLARE EXIT HANDLER FOR NOT FOUND SET medianSalary = 6666;
    SET medianSalary = 0;
    SELECT COUNT(*) INTO v_numRecords FROM staff;
    OPEN c1;
    WHILE v_counter < (v_numRecords / 2 + 1) DO
         FETCH c1 INTO medianSalary;
         SET v_counter = v_counter + 1;
    END WHILE;
    CLOSE c1;
    OPEN c2;
    OPEN c3;
END;

Les result sets arrivent dans l'ordre d'ouverture des curseurs (c2 puis c3). Côté composant, dans le bloc @code :

private List above = new();
private List belowOrEqual = new();

private async Task LoadMultipleResultSets()
{
    above.Clear();
    belowOrEqual.Clear();

    await using var conn = await Db2Service.CreateConnectionAsync();
    await using var cmd = new NTiCommand("MDSALARY.MEDIAN_MULTI", conn);
    cmd.CommandType = CommandType.StoredProcedure;

    var param = new NTiParameter
    {
        ParameterName = "medianSalary",
        Direction = ParameterDirection.Output
    };
    cmd.Parameters.Add(param);

    await using var reader = await cmd.ExecuteReaderAsync();
    median = Convert.ToDecimal(param.Value);

    // Premier result set : curseur c2
    while (await reader.ReadAsync())
    {
        above.Add(new Employee
        {
            Name = reader.GetString(0),
            Job = reader.GetString(1),
            Salary = reader.GetDecimal(2)
        });
    }

    // Passage au result set suivant : curseur c3
    if (await reader.NextResultAsync())
    {
        while (await reader.ReadAsync())
        {
            belowOrEqual.Add(new Employee
            {
                Name = reader.GetString(0),
                Job = reader.GetString(1),
                Salary = reader.GetDecimal(2)
            });
        }
    }
}

💡 Si le nombre de result sets n'est pas connu à l'avance, bouclez avec do { ... } while (await reader.NextResultAsync());. Avec Dapper, l'équivalent est QueryMultipleAsync.


Et maintenant ?

Reconnexion au serveur...

La connexion au serveur a été perdue. La page va se recharger.