mercredi 9 avril 2014

Curseurs et pgsql

Dans le billet précédent, nous avons parlé de l'utilisation des variables dans des instructions SQL.
Nous avons regretté le fait que
SELECT ...
INTO ...
sert à créer une nouvelle table et non pas à donner une valeur à une variable.
En effet nous aurions aimé pouvoir exécuter
SELECT code_u
INTO :code
FROM utilisations
WHERE signification = 'Santé';
et ensuite
SELECT *
FROM dépenses
WHERE code_u = :'code';
Mais cela ne fonctionne pas.

D'où l'idée de créer avec le langage PL/pgSQL une procédure (appelée par une fonction), procédure dans laquelle SELECT... INTO... fonctionne comme nous le voulons.
Voici les instructions qui nous ont servi à créer cette fonction:


CREATE FUNCTION fedépenses (text)
RETURNS SETOF dépenses AS $$
DECLARE
code dépenses.code_u%TYPE;
usage alias for $1;
fedépenses refcursor;
dépenses RECORD;
BEGIN
SELECT code_u
INTO code
FROM utilisations
WHERE signification = usage;
OPEN fedépenses FOR SELECT * FROM dépenses WHERE code_u = code ORDER BY référence;
 LOOP 
 FETCH NEXT FROM fedépenses INTO dépenses;
   EXIT WHEN NOT FOUND;
 RETURN NEXT dépenses;
END LOOP;
CLOSE fedépenses;
RETURN;
END;
$$ LANGUAGE 'plpgsql';

Le 'RETURNS SETOF' est nécessaire si on veut que la fonction puisse retourner un ensemble de rangées.
La variable code est définie avec le même type que le champ dépenses.code_u.
fedépenses est déclaré en tant que curseur non lié. Il est lié à un query seulement lors de son ouverture.
Pour le reste, assez classiquement nous passons en revue avec FETCH les différentes rangées lisibles par le curseur et ce jusqu'à la dernière.
Après avoir créé la fonction, il reste à la tester:


L'output est calamiteux (EDIT: voir plus loin), mais nous avons ce que nous voulons.
Nous avons également testé une variante de la fonction précédente:

CREATE FUNCTION fe2dépenses (text)
RETURNS SETOF dépenses AS $$
DECLARE
code dépenses.code_u%TYPE;
usage alias for $1;
fedépenses CURSOR (code text) IS 
 SELECT * FROM dépenses WHERE code_u = code ORDER BY référence;
BEGIN
SELECT code_u
INTO code
FROM utilisations
WHERE signification = usage;
FOR dépenses IN fedépenses (code) LOOP
RETURN NEXT dépenses;
 END LOOP;
RETURN;
END;
$$ LANGUAGE 'plpgsql';

Le curseur est maintenant déclaré lié à un query paramétré. Le curseur est ouvert automatiquement avec l'instruction FOR (il ne doit pas être ouvert avant) et il est fermé automatiquement lorsque la boucle se termine. Chaque rangée lue par le curseur est successivement assignée au record dépenses qui est créé automatiquement et qui n'existe que pendant la durée de la boucle.
Et voici le test:


Il n'est d'ailleurs pas nécessaire de définir un curseur. Ceci fonctionne également:

CREATE FUNCTION fe3dépenses (text)
RETURNS SETOF dépenses AS $$
DECLARE
code dépenses.code_u%TYPE;
usage alias for $1;
d dépenses%ROWTYPE;
BEGIN
SELECT code_u
INTO code
FROM utilisations
WHERE signification = usage;
 FOR d IN SELECT * FROM dépenses WHERE code_u = code ORDER BY référence
 LOOP 
 RETURN NEXT d;  
END LOOP;
RETURN;
END;
$$ LANGUAGE 'plpgsql';

Mais dans tous les cas, l'output est toujours aussi calamiteux.

EDIT: pour cette fonction (fe3dépenses), SELECT * FROM permet d'éviter l'output calamiteux. Il en est de même pour les deux fonctions précédentes à condition que le type indiqué dans la clause RETURNS soit 'dépenses' et non 'refcursor' qui ne fonctionne plus. Ceci est possible car la sortie de ces fonctions est constituée de rangées complètes (avec toutes les colonnes) d'une seule table.

Alors pourquoi ne pas se servir de PL/pgSQL uniquement pour créer un curseur et utiliser celui dans le terminal psql?
Pour cela, créons la fonction ocdépenses:

CREATE FUNCTION ocdépenses (refcursor, text)
RETURNS refcursor AS $$
DECLARE
usage alias for $2;
code dépenses.code_u%TYPE;
BEGIN
SELECT code_u
INTO code
FROM utilisations
WHERE signification = usage;
OPEN $1 for SELECT * FROM dépenses WHERE code_u = code ORDER BY référence;
RETURN $1;
END;
$$ LANGUAGE 'plpgsql';

Testons:


Ah oui: le curseur n'est vivant que pendant la durée d'une transaction. Or la transaction qui a été implicitement ouverte au lancement du query
select ocdépenses('curs1', 'Carburant')
est fermée automatiquement lorsque celui-ci s'est exécuté.
Corrigeons le tir en ouvrant explicitement la transaction avec BEGIN:


COMMIT termine la transaction et ferme donc le curseur.

Certains diront que l'on pouvait arriver à ce résultat avec:

select *
from dépenses
where code_u =
(select code_u
from utilisations
where signification = 'Carburant')
;
et même avec
select A.*
from dépenses A natural join utilisations B
where signification = 'Carburant'
;

A cela nous répondons:

  1. c'est beaucoup moins amusant
  2. le SQL se trouve alors au niveau du client ce qui dans le cas de traitement lourd peut présenter certains désavantages (ce n'est pas le cas ici)
  3. le but était de parler des variables
  4. comment alors présenter nos amis les curseurs?

Et les curseurs offrent de nombreuses possibilités. On n'est pas obligé de se limiter à 'fetch all'. Par exemple, si curs1 a été défini pour 'Grande surface':


etc...

Suite au 'fetch all', le curseur est positionné après la dernière rangée. C'est pourquoi l'instruction suivante nous amène à l'avant-dernière rangée. Le deuxième 'fetch relative -2' montre que l'on remonte bien de 2 positions.

dimanche 6 avril 2014

Variables dans psql

Il nous est loisible de définir et d'utiliser dans un terminal psql des variables sur le modèle des host variables qui existent pour du SQL embarqué dans un programme C (ou cobol).
Illustrons ce fait à l'aide d'instruction portant sur la table opérations définie ici:


(Remarquons que la méta-commande \pset numericlocale on concerne uniquement le format de sortie des nombres)
Autre exemple:


Malheureusement l'instruction
SELECT ...
INTO ...
sert au niveau de psql à créer une nouvelle table et non pas à stocker le résultat d'une requête dans une variable (contrairement à ce qui existe pour le SQL embarqué).
Il est cependant possible de contourner le problème comme dans cet exemple:


Les tables dépenses et utilisations ont été définies pour l'article Le piège du null. Depuis le libellé 'Médecin' a été remplacé par 'Santé' et un code a été attribué à toutes les dépenses.

Terminons par un exemple montrant les avantages de l'utilisation de telles variables.
Modifions comme ceci le fichier bilan.sql figurant dans ce billet:

\set QUIET
\set an 2013
\set titre 'Bilan ':an
\pset numericlocale on
\pset footer off
\pset linestyle u
\pset title :titre
\pset border 2
\o | awk 'NR==4 {y=$0};/SOUS-TOTAL/ {print y;print;print y};!/SOUS-TOTAL/'
SELECT mois_n ||'-' || mois AS "Mois",  '' AS " ", reference,
date_exec::text, crédit, débit,
  (SELECT SUM(solde)
  FROM opérationsv B
  WHERE B.reference <= A.reference
  AND B.an = A.an
  AND B.mois_n = A.mois_n) AS solde,
  contrepartie AS "usage"
FROM opérationsv A
WHERE an = :'an'
UNION
SELECT mois_n || '-' || mois, 'SOUS-TOTAL', '', '',
SUM(crédit) AS crédit, SUM(débit) AS débit, SUM(solde) AS solde,
'' AS " "
FROM opérationsv
WHERE an = :'an'
GROUP BY mois_n, mois
UNION
SELECT 'Ensemble', 'TOTAL', '', '',
SUM(crédit) AS crédit, SUM(débit) AS débit, SUM(solde) AS solde,
'' AS " "
FROM opérationsv
WHERE an = :'an'
ORDER by 1, 2 , 3;
\pset footer
\pset title
\pset border 1
\unset QUIET
\o

Comparant avec le fichier original, nous constatons que pour passer du bilan 2013 au bilan 2014, il nous suffit maintenant de remplacer 2013 par 2014 en un seul endroit (au lieu de 4).

Une autre possibilité est de supprimer \set an 2013 du fichier bilan.sql: le même fichier d'instructions pourra alors servir pour n'importe quelle année (il faut bien sûr initialiser la variable an):



mardi 18 mars 2014

Tableau croisé dans terminal psql

Pour le fun nous avons essayé de reproduire, dans un terminal postgresql, le tableau:

(voir ce billet)

Commençons par afficher par exemple la cellule (cela, avr.) avec

select distinct contrepartie, (select  sum(solde) 
from opérationsv 
where contrepartie = 'cela'
and   an = '2013'
and   mois_n = '04') AS "Avril"
from opérationsv
where contrepartie = 'cela'



Modifions l'instruction SQL de manière telle que la condition contrepartie =  'cela' n'apparaisse qu'une seule fois (dans la requête principale). Dans le même ordre d'idée, transportons la condition an = '2013' de la sous-requête à la requête principale':

select distinct contrepartie, (select  sum(solde) 
from opérationsv B
where B.contrepartie = A.contrepartie
and   B.an = A.an
and   mois_n = '04') AS "Avril"
from opérationsv A
where an = '2013'
and contrepartie = 'cela'

Ces modifications ne changent en rien le résultat obtenu par exécution de l'instruction, c'est-à-dire l'affichage de la cellule (cela, avr.).

Pour compléter la colonne "Avril", il suffit d'enlever la dernière condition:


Il manque le total de la colonne avril. Ajoutons le:

select contrepartie, (select  sum(solde) 
from opérationsv B
where B.contrepartie = A.contrepartie
and   B.an = A.an
and   mois_n = '04') AS "Avril"
from opérationsv A
where an = '2013'
UNION
select 'ZZ-Total', (select  sum(solde) 
from opérationsv B
where  B.an = A.an
and   mois_n = '04')
from opérationsv A
where an = '2013'
order by 1

(la clause UNION rend le distinct superflu)


Pourquoi 'ZZ-Total'? Simplement pour que l'order by le place en dernière position.
Il reste à ajouter les colonnes pour les autres mois, la colonne 'Total' (avec une sous-requête du même genre que celles qui donnent les colonnes mois) et à améliorer le look de l'output.

Bref, voici le fichier bilancroisé.sql qui sera exécuté dans un terminal psql connecté à la base de données bdtest:

\set QUIET
\pset numericlocale on
\pset footer off
\pset linestyle u
\pset title 'Bilan Croisé 2013'
\pset border 2
\o | awk 'NR==4 {y=$0};/ZZ-TOTAL/ {print y};1'
select contrepartie AS "Usage"
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an
                      and   mois_n = '01') as "Janvier "
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an
                      and   mois_n = '02') as "Février "
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an
                      and   mois_n = '03') as "  Mars  "
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an
                      and   mois_n = '04') as "  Avril "
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an
                      and   mois_n = '05') as "   Mai  "
, (select sum(solde)
     from opérationsv B
                      where B.contrepartie = A.contrepartie
                      and   B.an = A.an) as "Total"
from opérationsv A
where an = '2013'
UNION
select 'ZZ-TOTAL'
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   mois_n = '01') 
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   mois_n = '02') 
, (select sum(solde)
     from opérationsv B                     
                      where   B.an = A.an
                      and   mois_n = '03') 
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   mois_n = '04') 
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   mois_n = '05') 
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an) 
from opérationsv A
where an = '2013'
order by 1
;
\pset title
\pset border 1
\unset QUIET
\o


L'output est amélioré grâce aux instructions awk que voici:
  1. NR==4 {y=$0}
  2. /ZZ-TOTAL/ {print y}
  3. 1
Instruction 1: la ligne de séparation (la 4ième ligne) est sauvegardée en y
Instruction 2: si la ligne lue par awk comprend 'ZZ-TOTAL': impression de la ligne de séparation
Instruction 3: la condition 1 est toujours vraie, donc toutes les lignes lues sont imprimées (action par défaut).

Et voici le résultat:




Le tableau est déjà prêt pour recevoir les données du mois de Mai.
Pour les autres mois il faudra modifier le SQL en conséquence.
On peut également construire le tableau à l'envers avec les instructions (contenues dans bilancroisé2.sql):


\set QUIET
\pset numericlocale on
\pset footer off
\pset linestyle u
\pset title 'Bilan Croisé 2013'
\pset border 2
\o | awk 'NR==4 {y=$0};/TOTAL/ {print y};1' | tee bilancroisé2.txt
select mois_n || ' ' || mois AS "Mois"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'Autre') AS "Autre"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'ceci') AS "ceci"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'cela') AS "cela"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'Div') AS "Div"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'GS') AS "GS"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'Loyer') AS "Loyer"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'RN1') AS "RN1"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'RN2') AS "RN2"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'Tél') AS "Tél"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'TV') AS "TV"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an
                      and   contrepartie = 'VOIT') AS "VOIT"
, (select sum(solde)
     from opérationsv B
                      where B.mois_n = A.mois_n
                      and   B.an = A.an) AS "Total"
from opérationsv a
where an = '2013'
UNION
select 'TOTAL'
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'Autre')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'ceci')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'cela')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'Div')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'GS')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'Loyer')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'RN1')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'RN2')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'Tél')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'TV')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an
                      and   contrepartie = 'VOIT')
, (select sum(solde)
     from opérationsv B                      
                      where   B.an = A.an)
from opérationsv a
where an = '2013'
;
\pset title
\pset border 1
\pset footer on
\unset QUIET
\o

Ce qui donne:



Pour autant que les postes de dépenses et de recettes ne changent pas, le SQL ne doit pas être adapté en cas de nouvelles opérations en 2013: les mois s'ajouteront automatiquement.

(Pour la définition de la table et les données voir la fin de l'article précédent)

Sous-totaux dans terminal psql

Nous allons dans un terminal psql (connecté à la base de données bdtest) produire un tableau avec sous-totaux par mois tel que celui construit dans ce billet.
L'instruction SQL se présentera suivant le schéma:

SELECT ligne détail
UNION
SELECT ligne sous-total
UNION
SELECT ligne total

étant entendu que chaque SELECT devra produire le même nombre de colonne avec le même type de données. Ainsi si la 4ième colonne de la ligne détail est une date, il faudra que cela soit aussi une date pour les deux SELECT suivant, ce qui évidemment posera un grave problème. C'est pourquoi nous afficherons dans la ligne détail les dates avec le type 'text'. Toutes les instructions nécessaire à la production du tableau seront placées dans un fichier bilan.sql que voici:


\set QUIET
\pset numericlocale on
\pset footer off
\pset linestyle u
\pset title 'Bilan'
\pset border 2
\o | awk 'NR==4 {y=$0};/SOUS-TOTAL/ {print y;print;print y};!/SOUS-TOTAL/'
SELECT mois_n ||'-' || mois AS "Mois",  '' AS " ", reference,
date_exec::text, crédit, débit,
  (SELECT SUM(solde)
  FROM opérationsv B
  WHERE B.reference <= A.reference
  AND B.an = A.an
  AND B.mois_n = A.mois_n) AS solde,
  contrepartie AS "usage"
FROM opérationsv A
WHERE an = '2013'
UNION
SELECT mois_n || '-' || mois, 'SOUS-TOTAL', '', '',
SUM(crédit) AS crédit, SUM(débit) AS débit, SUM(solde) AS solde,
'' AS " "
FROM opérationsv
WHERE an = '2013'
GROUP BY mois_n, mois
UNION
SELECT 'Ensemble', 'TOTAL', '', '',
SUM(crédit) AS crédit, SUM(débit) AS débit, SUM(solde) AS solde,
'' AS " "
FROM opérationsv
WHERE an = '2013'
ORDER by 1, 2 , 3;
\pset footer
\pset title
\pset border 1
\unset QUIET
\o

La deuxième colonne de la ligne détail est une colonne vide qui laissera la place au mot 'SOUS-TOTAL' et 'TOTAL' pour les deux SELECT suivant.
L'output est envoyé dans awk en vue de quelques améliorations.
Examinons de plus près les instructions awk:
  1. NR==4 {y=$0}
  2. /SOUS-TOTAL/ {print y;print;print y}
  3. !/SOUS-TOTAL/
Instruction 1: la ligne de séparation qui est la 4ième ligne lue est sauvegardée dans y
Instruction 2: toute ligne contenant le mot 'SOUS-TOTAL' est imprimée précédée et suivie de y (ligne de séparation)
Instruction 3: toutes les lignes sont imprimées (action par défaut) sauf les lignes avec 'SOUS-TOTAL' puisqu'elles l'ont déjà été.
Et voici le résultat:



Pour rappel la table opérations ainsi que la vue opérationsv sont définies dans cet article.
Depuis lors les données suivantes ont été ajoutées:

2013-0020 2013-03-28 -956.00 Loyer 
2013-0021 2013-03-31 1874.56 RN1
2013-0022 2013-03-31 -78.45 GS
2013-0023 2013-04-05 -119.23 cela
2013-0024 2013-04-12 -303.54 Tél
2013-0025 2013-04-15 -46.00 cela

lundi 17 mars 2014

Bilans mensuels (variante)

Revenons sur le contenu de cet article. Pour la réalisation des rapports libreoffice, nous avions travaillé avec une vue qui fournissait le mois de l'opération à partir de la date d'exécution (date_exec). Mais cela n'est pas nécessaire. Nous pouvons par exemple prendre comme source de données la vue basée sur la table opérations et créée par cet instruction:

CREATE VIEW opérationsvs AS 
SELECT substr(a.reference::text, 1, 4) AS an,
a.reference,
a.date_exec, 
        CASE
            WHEN a.montant > 0::numeric THEN a.montant
            ELSE 0::numeric
        END AS crédit, 
        CASE
            WHEN a.montant < 0::numeric THEN - a.montant
            ELSE 0::numeric
        END AS débit, 
a.montant AS solde, 
a.contrepartie,
( SELECT SUM( montant )
 FROM opérations b
 WHERE b.reference <= a.reference
 AND extract(year FROM b.date_exec) = extract(year FROM a.date_exec)
 AND extract(month FROM b.date_exec) = extract(month FROM a.date_exec) 
) AS solde_c
   FROM opérations a

Là où on affichait le mois (comme en-tête de groupe, au dessus du trait rouge), on mettra maintenant la date


mais évidemment avec un format approprié:


Reste à s'occuper du groupement:


Et voilà!
Ci-dessous par exemple, les opérations de février (page 3 du rapport) avec le solde relatif aux opérations de février seulement:


vendredi 14 mars 2014

Tableau croisé avec des données postgresql (suite)

Nous n'allons plus cette fois comme dans le billet précédent lier directement le tableau à une table postgresql, mais procéder de manière indirecte.
Après appui sur F4 nous choisissons la table "opérations" (de la base de données bdtest) comme source de données. Nous n'appliquons aucun filtre, aucun tri.
Après sélection de l'ensemble de la table (en cliquant sur le rectangle gris du coin supérieur gauche)


nous insérons les données dans le tableur via l'icône "Données dans le texte":


Ensuite comme auparavant nous passons par Données => Table du pilote => Créer, mais au moment de sélectionner la source nous optons pour "Sélection active":


Il reste à procéder en faisant glisser ce qui convient là où ça convient...


pour obtenir ceci:


Cette méthode présente deux avantages par rapport à la précédente:

  • apparition de la cellule "Filtrer" (qui refusait obstinément d'apparaître en cas de lien direct)
  • les dates sont directement au bon format (alors que précédemment 30/12/12 s'affichait 41273)
Cliquer sur "Filtrer" nous permet de ne retenir que les dates de 2013:


Il reste à grouper par mois (via F12):


Attention: en cas de modification des données, il faut cette fois actualiser la feuille qui contient les données (et qui elle est liée à la table postgresql):
Ouvrir le navigateur avec F5:


Double-cliquer sur la plage de donnée adéquate (par défaut Importer1 si on n'a effectué qu'une importation), puis actualiser la plage:


En principe le tableau croisé devrait être mis à jour automatiquement, ou sinon procéder comme précédemment: clic droit sur le tableau puis choisir 'Actualiser' dans le menu contextuel qui surgit.