Hlavní navigace

Uložené procedury, event scheduler a informační schémata MySQL

31. 7. 2006
Doba čtení: 13 minut

Sdílet

Když jsem se poprvé setkal s rekurzí, vůbec jsem nechápal, o co jde. Většina překladačů a interpretů ji nepodporovala a spíš se hledaly nerekurzivní řešení. Staré časy - MicroBáze Pascalu, TurboPascalu. Jsem rád, že mám na co vzpomínat. Dost nostalgie. Rekurze tu je.

I když mi ještě něco uteklo, časy děrných štítků, počítačů Ural. Doba, kdy výpočet výplat pro 10 tisíc lidí trval celou noc a pro operátory to bylo super dobrodružství. Slyšel jsem, že někde se starší dáma podívala na výslednou sestavu, a když se jí něco nezdálo, tak se počítalo znova, až se jí to zdálo. Sedím u nadupaného notesu, snad 1000× rychlejšího než CP/M na mém didaktiku, a hned bych měnil.

Dost nostalgie. Rekurze tu je. Není to abstraktní konstrukce, stačí se podívat kolem sebe. Jedna z větví databázových systémů, dodnes žijící, i když už zdaleka ne tak markantně (bez ohledu na to, co si těch pár nadšenců myslí), to respektuje a podporuje velice efektivní práci se stromy. Jsou to tzv. síťové databáze. Relační databáze nejsou na rekurzi zdaleka tak dobře vybavené (existují dvě rozšíření – Oracle a ANSI, které tuto problematiku jistým způsobem řeší). 100% nepřipravené jsou open-source databáze. Pro PostgreSQL existuje patch, který byl ale zamítnut (podporuje obě syntaxe: Oracle i ANSI). Podporu rekurze by měl obsahovat Firebird 3.0. O co jde? Většinou o úlohu prohledání stromu do šířky nebo do hloubky. Bez podpory na úrovni SQL musíme tuto úlohu řešit na aplikační úrovni nebo pomocí uložených procedur. Pozn.: existují minimálně tři řešení uložení rekurzivních dat, kdy můžeme získat seznam potomků, aniž bychom museli rekurzivně volat proceduru.

Implicitně MySQL nedovoluje rekurzivní volání procedur. To ale nepředstavuje žádný problém. Stačí změnit systémovou proměnnou max_sp_recursi­on_depth:

  set @@max_sp_recursion_depth=32;

Důležitou roli v SQL/PSM hraje zachytávání výjimek. Je to určité specifikum tohoto jazyka a ostatně specifický je i způsob, jak jsou výjimky ošetřeny. Na rozdíl od většiny ostatních jazyků, kde se používají konstrukce a la strukturované výjimky, SQL/PSM používá chybové handlery – subrutiny, které se aktivují při chybě. Lze zachytit konkrétní i libovolnou chybu. Což ovšem nedoporučuji, protože v MySQL zatím neexistuje způsob, jak detekovat důvod chyby, a tudíž se o chybě nedozvíte vůbec nic. Např. iterace nad výsledkem dotazu se v SQL/PSM řeší následovně:

1   BEGIN
2     DECLARE done BOOLEAN DEFAULT false;
3     DECLARE v_a, v_b VARCHAR(20);
4     DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;
5     DECLARE c CURSOR FOR SELECT a,b FROM mytab;
6     OPEN c;
7     FETCH c INTO v_a, v_b;
8     WHILE NOT done DO
9       FETCH c INTO v_a, v_b;
10    END WHILE;
11    CLOSE c;
12  END;

V případě, že kurzor narazí na konec množiny v příkazu FETCH [7,9], aktivuje se obsluha chyby NOT FOUND. V tomto případě se jedná o tzv. CONTINUE HANDLER, tj. spustí se ošetření chyby (nastavení proměnné done [4]) a pokračuje se následujícím příkazem [8,10]. Příkaz FETCH načte jeden řádek z vstupní množiny otevřeného [6] kurzoru. Načtené sloupce se uloží do lokálních proměnných v_a a v_b. Doplním, kurzory se v SQL používají pro sekvenční přístup k datům. Otevřený kurzor se uvolňuje příkazem CLOSE [11].

Funkčně stejný kód demonstrující cyklus REPEAT UNTIL:

1   BEGIN
2     DECLARE done BOOLEAN DEFAULT false;
3     DECLARE v_a, v_b VARCHAR(20);
4     DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;
5     DECLARE c CURSOR FOR SELECT a,b FROM mytab;
6     OPEN c;
7     REPEAT
8       FETCH c INTO v_a, v_b;
9       IF not done THEN
          ...
10      END IF;
11    UNTIL done END REPEAT;
12    CLOSE c;
13  END;

Jazykoví puristé a experti se mohou přít, která varianta je elegantnější a lepší. Mně osobně přijde jedna za osmnáct a druhá bez dvou za dvacet. V dokumentaci MySQL se používá druhá varianta. Nejlepší varianta je ta, kterou zatím MySQL nepodporuje – použití konstrukce FOR. Následující příklad je modifikací příkladu z firemní dokumentace Petra Gultuzana. Odstranil jsem pouze nesmyslné zachytávání chyb, což je styl, který rozhodně na veřejnosti propagovat nehodlám. Následující dvě procedury vypíší seznam potomků zadaného prvku. Nejdříve vytvoříme dočasnou tabulku [8], která se naplní rekurzivním voláním druhé procedury [12]. Poté se provede volný SELECT této dočasné tabulky [14](tj. tímto způsobem dostaneme výsledek ven z procedury) a dočasná tabulka se odstraní [15]. Druhá procedura není nijak robustní. Pokud by data nereprezentovala strom, dojde k chybnému výsledku nebo zacyklení. Druhá procedura iteruje nad potomky [28] – uloží záznam do dočasné tabulky (tabulky s výsledkem) [32] a rekurzivně spouští sama sebe pro dohledání potomků [33].

1   DELIMITER //
2   DROP PROCEDURE IF EXISTS hierarchy//
3   CREATE PROCEDURE hierarchy (start_with CHAR(10))
4   BEGIN
5     DECLARE v_person_id, v_father_id INT;
6     DECLARE v_person_name CHAR(20);
7     DROP TABLE IF EXISTS Temporary_Table;
8     CREATE TEMPORARY TABLE Temporary_Table (
        person_id INT,
        person_name CHAR(20),
        father_id INT,
        level INT
      );
9     SELECT person_id, person_name INTO v_person_id, v_person_name
        FROM Persons WHERE person_name = start_with LIMIT 1;
10    IF NOT v_person_id IS NULL THEN
11      INSERT INTO Temporary_Table VALUES
          (v_person_id, v_person_name, v_father_id, 0);
12      CALL hierarchy2(v_person_id, 1);
13    END IF;
14    SELECT person_id, person_name, father_id, level
        FROM Temporary_Table ORDER BY level;
15    DROP TEMPORARY TABLE Temporary_Table;
16  END; //
17  DELIMITER ;

18  DELIMITER //
19  DROP PROCEDURE IF EXISTS hierarchy2//
20  CREATE PROCEDURE hierarchy2(start_with INT, level INT)
21  BEGIN
22    DECLARE v_person_id, v_father_id INT;
23    DECLARE v_person_name CHAR(20);
24    DECLARE done BOOLEAN DEFAULT FALSE;
25    DECLARE c CURSOR FOR
        SELECT person_id, person_name, father_id
        FROM Persons WHERE father_id = start_with;
26    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
27    OPEN c;
28    REPEAT
29      SET v_person_id=NULL;
30      FETCH c INTO v_person_id, v_person_name, v_father_id;
31      IF done=FALSE THEN
32        INSERT INTO Temporary_Table VALUES
            (v_person_id, v_person_name, v_father_id, level);
33        CALL hierarchy2(v_person_id,level+1);
34      END IF;
35    UNTIL done END REPEAT;
36    CLOSE c;
37  END; //
38  DELIMITER ;

Abychom mohli kód spustit, potřebujeme testovací data:

CREATE TABLE Persons (
  person_id INT,
  person_name CHAR(20),
  father_id INT
);
INSERT INTO Persons VALUES (1,'Grandpa',NULL);
INSERT INTO Persons VALUES (2,'Pa-1',1),(3,'Pa-2',1);
INSERT INTO Persons VALUES (4,'Grandson-1',2),(5,'Grandson-2',2);

Po spuštění získáme tabulku:

mysql> call hierarchy('Grandpa');
call hierarchy('Grandpa');
+-----------+-------------+-----------+-------+
| person_id | person_name | father_id | level |
+-----------+-------------+-----------+-------+
| 1         | Grandpa     | NULL      | 0     |
| 2         | Pa-1        | 1         | 1     |
| 3         | Pa-2        | 1         | 1     |
| 4         | Grandson-1  | 2         | 2     |
| 5         | Grandson-2  | 2         | 2     |
+-----------+-------------+-----------+-------+
5 rows in set (0,00 sec)

Query OK, 0 rows affected, 1 warning (0,00 sec)

Inspirací pro následující příklad byla Oracle XE. Přiznám se, že www rozhraní mne příjemně překvapilo. U předchozích verzí jsem měl pocit, že snad s nimi nikdo kromě studentů a pár zoufalců nemůže pracovat a ostatní raději používají příkazovou řádku. Jednou z funkcí je i zobrazení statistik velikosti a růstu tabulek. Napsal jsem tedy uloženou proceduru, která zobrazí deset největších tabulek a deset nejrychleji rostoucích tabulek. Kromě jiného je i ukázkou možného použití multirecordsetu. Prvním úkolem je získání a archivace údajů o velikosti tabulky. Obecně se tyto hodnoty získávají ze systémových tabulek. ANSI SQL přináší tzv. informační schémata, které MySQL 5 implementuje. V podstatě se jedná o standardizované pohledy do systémových tabulek. Díky nim odpadá jeden problém při portaci aplikací – prohledávání struktury databáze. Příkaz SELECT table_name FROM information_sche­ma.tables bude funkční na PostgreSQL, MySQL a všech ostatních databázích, které v tomto bodě respektují ANSI SQL.

1   DELIMITER //
2   DROP PROCEDURE IF EXISTS set_stat //
3   CREATE PROCEDURE set_stat (schma VARCHAR(64))
4   BEGIN
5     DECLARE done, cursor_done BOOLEAN DEFAULT FALSE;
6     DECLARE v_table_name VARCHAR(64);
7     DECLARE v_table_rows, v_data_length INTEGER;
8     DECLARE v_hist_rows, v_hist_length INTEGER;
9     DECLARE c CURSOR FOR
10      SELECT table_name, table_rows, data_length
        FROM information_schema.tables WHERE table_schema = schma;
11    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
12    IF (SELECT table_name FROM information_schema.tables
        WHERE table_name = 'size_log') IS NULL THEN
13      CREATE TABLE size_log (
          table_schema VARCHAR(64),
          table_name VARCHAR(64),
          table_rows INTEGER,
          data_length INTEGER,
          inserted TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
15    END IF;
16    OPEN c;
17    REPEAT
18      FETCH c INTO v_table_name, v_table_rows, v_data_length;
19      IF v_table_name <> 'size_log' THEN
13        SET cursor_done = done;
14        SET v_hist_rows = NULL;
15        SELECT table_rows, data_length INTO v_hist_rows, v_hist_length
            FROM size_log WHERE table_name = v_table_name AND table_schema = schma
            ORDER BY inserted DESC LIMIT 1;
16        SET done = false; -- je nutno provest reset promenne
17        -- pridavam pouze nove zaznamy nebo zmeny
18        IF (v_hist_rows IS NULL)
            OR (v_hist_rows IS NOT NULL
            AND v_table_rows <> v_hist_rows AND v_data_length <> v_hist_length) THEN
19          INSERT INTO size_log(table_schema, table_name, table_rows, data_length)
              VALUES(schma, v_table_name, v_table_rows, v_data_length);
20        END IF;
21     END IF;
22   UNTIL cursor_done END REPEAT;
23   CLOSE c;
24  END //
25  DELIMITER ;

Procedura set_stat ukládá stav tabulek zadaného schématu. V případě, že neexistuje tabulka size_log [12], se vytvoří [13]. Poté iterace přes všechny tabulky [17] mimo tabulku size_log. Aktuální údaje porovnávám s archivovanými hodnotami [18] a pokud došlo ke změně, uložím aktuální údaje o velikosti tabulky [19]. Vzhledem k tomu, že mi flag NOT FOUND (a nepřímo i obsah proměnné done) může nahodit jak příkaz FETCH [18], tak příkaz SELECT INTO [15], musím si zálohovat obsah proměnné done [13] a opakovaně jej resetovat [16]. Dále, v případě, že SELECT INTO skončí neúspěšně (příznakem NOT FOUND), nepřepíše se obsah proměnných v_hist_rows, v_hist_length [15]. Protože proměnnou v_hist_rows používám k detekci, zda-li už mám záznam v tabulce size_log, musím ji nastavit na NULL [14].

1   DELIMITER //
2   DROP PROCEDURE IF EXISTS stat //
3   CREATE PROCEDURE stat (sort_by CHAR(1), schma VARCHAR(64))
4   BEGIN
5     DECLARE done, cursor_done BOOLEAN DEFAULT FALSE;
6     DECLARE v_table_name VARCHAR(64);
7     DECLARE v_table_rows, v_data_length INTEGER;
8     DECLARE v_hist_rows, v_hist_length INTEGER;
9     DECLARE c CURSOR FOR
10      SELECT table_name, table_rows, data_length
          FROM information_schema.tables WHERE table_schema = schma;
11    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
12    DROP TABLE IF EXISTS tmp_table;
13    CREATE TEMPORARY TABLE tmp_table (
        table_schema VARCHAR(64),
        table_name VARCHAR(64),
        table_rows INTEGER,
        data_length INTEGER
      );
14    IF (SELECT table_name FROM information_schema.tables
        WHERE table_name = 'size_log') IS NULL THEN
15      CREATE TABLE size_log (
          table_schema VARCHAR(64),
          table_name VARCHAR(64),
          table_rows INTEGER,
          data_length INTEGER,
          inserted TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
16    END IF;
17    OPEN c;
18    REPEAT
19      FETCH c INTO v_table_name, v_table_rows, v_data_length;
20      SET cursor_done = done;
21      SET v_hist_rows = NULL;
22      SELECT table_rows, data_length INTO v_hist_rows, v_hist_length
          FROM size_log WHERE table_name = v_table_name AND table_schema = schma
          ORDER BY inserted DESC LIMIT 1;
23      SET done = false; -- je nutno provest reset promenne
24      IF v_hist_rows IS NOT NULL
          AND v_table_rows <> v_hist_rows AND v_data_length <> v_hist_length THEN
25        INSERT INTO tmp_table VALUES(schma, v_table_name,
            v_table_rows - v_hist_rows, v_data_length - v_hist_length);
26      END IF;
27    UNTIL cursor_done END REPEAT;
28    CLOSE c;
29    CASE sort_by
30      WHEN 'r' THEN
31        SELECT table_schema, table_name, table_rows, data_length
            FROM information_schema.tables WHERE table_schema = schma
            ORDER BY table_rows DESC LIMIT 10;
32        SELECT * FROM tmp_table ORDER BY table_rows DESC LIMIT 10;
33      WHEN 'l' THEN
34        SELECT table_schema, table_name, table_rows, data_length
35          FROM information_schema.tables WHERE table_schema = schma
            ORDER BY data_length DESC LIMIT 10;
36        SELECT * FROM tmp_table ORDER BY data_length DESC LIMIT 10;
37    END CASE;
38    DROP TABLE tmp_table;
39  END; //
40  DELIMITER ;

V proceduře stat už skoro není co komentovat. Deset největších tabulek přebírá přímo z informačního schématu. Deset nejrychleji rostoucích tabulek získám dynamicky rozdílem hodnot z aktuálního stavu a stavu uloženém v tabulce size_log. Za zmínění snad stojí skutečnost, že konstrukce CASE v SQL/PSM není totožná s SQL příkazem CASE. Výsledek vidíte na následujícím výpisu:

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 1 to server version: 5.1.9-beta-log

Type 'help;' or '\h' for help. Type '\c' to clear the buffer.

mysql> call stat('l','mysql');
call stat('l','mysql');
+--------------+---------------+------------+-------------+
| table_schema | table_name    | table_rows | data_length |
+--------------+---------------+------------+-------------+
| mysql        | help_topic    | 464        | 253564      |
| mysql        | help_keyword  | 384        | 75648       |
| mysql        | help_category | 37         | 21497       |
| mysql        | help_relation | 740        | 6660        |
| mysql        | proc          | 8          | 5852        |
| mysql        | size_log      | 32         | 984         |
| mysql        | db            | 2          | 880         |
| mysql        | user          | 4          | 224         |
| mysql        | Persons       | 5          | 145         |
| mysql        | fx2           | 7          | 49          |
+--------------+---------------+------------+-------------+
10 rows in set (0,26 sec)

+--------------+------------+------------+-------------+
| table_schema | table_name | table_rows | data_length |
+--------------+------------+------------+-------------+
| mysql        | fx2        | 1          | 7           |
+--------------+------------+------------+-------------+
1 row in set (0,27 sec)

Query OK, 0 rows affected, 1 warning (0,28 sec)

Pokud bych chtěl poslední příklad vyšperkovat, nastavil bych automatické spouštění procedury set_stat pomocí Event Scheduleru. Event Scheduler (od verze 5.1) je obdoba Jobs v SQL serveru nebo crontabu v Unixu. Událost (Event) je databázový objekt obsahující jeden nebo několik SQL příkazů, které se provedou v předepsaný čas nebo se provádějí opakovaně v zadaném intervalu. Defaultně je plánovač událostí zablokován a je třeba jej explicitně povolit: SET @@global.even­t_scheduler = ON. K provedení tohoto příkazu potřebujete práva superuživatele. Pro vyzkoušení uvedu příklad, který každou minutu přidá záznam do tabulky test. Nicméně alespoň v mé instalaci je i v této funkci chyba. Události se aktivovaly, ovšem v daný okamžik se události přiřazený SQL příkaz provedl několikrát (když jsem používal jednovteřinový interval). S minutovým intervalem nebyly žádné problémy.

CREATE TABLE testt(t TIMESTAMP);
CREATE EVENT into_testt
  ON SCHEDULE EVERY 1 MINUTE DO
    INSERT INTO testt VALUES(CURRENT_TIMESTAMP);

Funkci set_stat určitě nebudeme volat po minutě. Dejme tomu jednou týdně. Potom událost můžeme definovat příkazem:

CREATE EVENT call_set_stat
  ON SCHEDULE EVERY 1 WEEK STARTS '2006-07-01 13:00:00' DO
    CALL set_stat();

Na závěr uvedu ještě slíbený příklad c# kódu (mono) pro volání uložené procedury. V Linuxu jsou k dispozici dva drivery. Volně dostupný ByteFX není již vyvíjen (a také nepodporuje uložené procedury), ale lze jej získat jako RPM balíček. Proto se zaměřím na druhý driver. Driver MySQL connector/NET sice podporuje SP, ale musíme jej nainstalovat ručně (pouze verze 1.0.7 a vyšší jsou funkční i mimo o.s. MS Windows). Pokud získáme knihovnu MySQL.Data.Dll (z mysql.com), musíme ji zaregistrovat příkazem:

gacutil -i MySql.Data.dll

Když jsem zkoušel metodologicky volat uložené procedury, opět jsem narazil na chybu. Ta ovšem může být způsobena použitím vývojové verze MySQL. Proto proceduru volám klasicky SQL příkazem CALL [13]. Jedno zavolání Readeru [16] mi vrací dva recordsety. Po přečtení prvého se přesunu na druhý voláním metody NextResult() [32]. Jinak tento příklad představuje vzorové čtení recordsetu prostřednictvím ADO.NET.

CS24 tip temata

1   using System;
2   using System.Data;
3   using MySql.Data.MySqlClient;

4   public class Test
5   {
6      public static void Main(string[] args)
7      {
8        string connectionString = "Database=mysql; User ID=root;";
9        IDbConnection dbcon;
10       dbcon = new MySqlConnection(connectionString);
11       dbcon.Open();
12       IDbCommand dbcmd = dbcon.CreateCommand();
13       dbcmd.CommandText = "CALL stat(?type,?scheme)";
14       dbcmd.Parameters.Add( new MySqlParameter("?type","l"));
15       dbcmd.Parameters.Add( new MySqlParameter("?scheme","mysql"));

16       IDataReader reader = dbcmd.ExecuteReader();

17       Console.WriteLine("".PadRight(62,'-'));
18       Console.WriteLine(" {0,-15} | {1,-15} | {2,10} | {3,10}",
                           "table schema",
                           "table name", "table rows", "data length");
19       Console.WriteLine("".PadRight(62,'-'));
20       Console.WriteLine(" Největší tabulky ");
21       Console.WriteLine("".PadRight(62,'-'));

22       while(reader.Read()) {
23         string table_schema = (string) reader["table_schema"];
24         string table_name = (string) reader["table_name"];
25         long table_rows = (long) reader["table_rows"];
26         long data_length = (long) reader["data_length"];
27         Console.WriteLine(" {0,-15} | {1,-15} | {2,10:g} | {3,10:g}",
                     table_schema,
                     table_name, table_rows, data_length);
28       }

29       Console.WriteLine("".PadRight(62,'-'));
30       Console.WriteLine(" Nejrychleji rostoucí tabulky ");
31       Console.WriteLine("".PadRight(62,'-'));

32       reader.NextResult();
33       while(reader.Read()) {
34         string table_schema = (string) reader["table_schema"];
35         string table_name = (string) reader["table_name"];
36         int table_rows = (int) reader["table_rows"];
37         int data_length = (int) reader["data_length"];
38         Console.WriteLine(" {0,-15} | {1,-15} | {2,10:g} | {3,10:g}",
                     table_schema,
                     table_name, table_rows, data_length);
39       }

40       reader.Close();
41       reader = null;
42       dbcmd.Dispose();
43       dbcmd = null;
45       dbcon.Close();
46       dbcon = null;
47     }
48  }

Skript přeložíme a spustíme dvojicí příkazů:

mcs mono-test.cs -r:System.Data.dll -r:MySql.Data.dll
mono mono-test.exe

V těchto článcích jsem zdaleka nepopsal veškerou funkcionalitu uložených procedur. Namátkou jsem opomněl: triggery, zabezpečení, charakteristiku funkce (DETERMINISTIC|NON DETERMINISTIC|MO­DIFIES SQL DATA atd), atd. Ani jsem se o to nepokoušel. Zatím jsem se nesetkal s nikým, kdo by SP v MySQL 5 použil v reálných aplikacích. Jedná se zatím o příliš žhavou novinku a odhaduji, že bude potřeba minimálně rok, dva než budou vychytány všechny chyby a než si ji osvojí programátoři – viz nástup PHP5.

Autor článku

Pavel Stěhule je odborníkem na relační databázový systém PostgreSQL, pracuje jako školitel a konzultant.