Курсоры в хранимых процедурах MySQL
После предыдущей статьи о хранимых процедурах, я получил целый ряд комментариев. В одном из них читатель попросил меня уделить больше внимания курсорам, одному из важных элементов хранимых процедур.
Так как курсоры являются частью хранимой процедуры, то в этой статье мы еще детальнее рассмотрим ХП. В частности, как извлечь из ХП набор данных.
Что такое курсор?
Курсор не может использоваться в MySQL сам по себе. Он является важным компонентом хранимых процедур. Я бы сравнил курсор с «указателем» в C / C + + или итератором в PHP-операторе foreach.
С помощью курсора мы можем перебрать набор данных и обработать каждую запись в соответствии с определенными задачами.
Такая операция по обработке записи может быть также исполнена на PHP-уровне, что значительно уменьшает объем передаваемых на PHP-уровень данных, так как мы можем просто вернуть обработанный сводный / статистический результат обратно (тем самым устраняя процесс обработки select – foreach на стороне клиента).
Поскольку курсор реализуется в хранимой процедуре, он имеет все преимущества (и недостатки), присущие ХП (контроль доступа, пре-компиляция, трудность отладки и т.д.)
Официальную документацию по курсорам вы можете найти здесь. В ней описаны четыре команды, связанные с объявлением курсора, открытием, закрытием и извлечением. Как уже упоминалось, мы также затронем некоторых другие операторы хранимых процедур. Давайте приступим.
Пример практического применения
На моем персональном сайте есть страница с результатами игр моей любимой команды НБА: Лейкерс.
Структура таблицы этой страницы довольно проста:

Рис 1. Структура таблицы результатов игр Лейкерс
Я заполняю эту таблицу с 2008 года. Некоторые из последних записей с результатами игр Лейкерс в сезоне 2013-14 приведены ниже:

Рис. 2. Данные таблицы результатов игр Лейкерс (частичные) в сезоне 2013-2014
(Я использую MySQL Workbench в качестве GUI-инструмента для управления базой данных MySQL. Вы можете использовать другой инструмент по своему выбору).
Что ж, должен признать, что баскетболисты Лейкерс в последнее время играют не очень здорово. 6 поражений подряд по состоянию на 15 января. Я определил эти «6 поражений подряд», посчитав вручную, сколько матчей подряд, начиная с текущей даты (и вниз к более ранним играм) имеют в колонке winlose значение «L» (поражение).
Это, конечно, не невыполнимая задача, однако если условия усложнятся, и таблица данных будет намного больше, то это займет больше времени, и вероятность ошибки также увеличивается.
Можем ли мы сделать то же самое с помощью одного оператора SQL? Я не являюсь экспертом SQL, потому не смог придумать, как достичь нужного результата («6 поражений подряд») через один оператор SQL. Мнения гуру будут для меня очень ценными - оставьте их в комментариях ниже.
Можем ли мы сделать это через PHP? Да, конечно. Мы можем получить данные по играм (конкретно, столбец winlos) этого сезона и перебрать записи для вычисления длительности текущей серии побед / поражений подряд.
Но чтобы сделать это, нам придется охватить все данные за этот год и большая часть данных будет для нас бесполезна (не слишком вероятно, что какая-то команда будет иметь серию длиннее, чем 20+ игр подряд в регулярном сезоне, который состоит из 82 матчей).
Тем не менее, мы не знаем наверняка, сколько записей должно быть извлечено в PHP для определения серии. Так что нам не обойтись без напрасного извлечения ненужных данных. И, наконец, если текущее количество выигрышей /поражений подряд это единственное, что мы хотим узнать из этой таблицы, зачем нам тогда извлекать все строки данных?
Можем ли мы сделать это другим способом? Да, это возможно. Например, мы можем создать резервную таблицу, специально предназначенную для хранения текущего значения количества побед /поражений подряд.
Добавление каждой новой записи будет автоматически обновлять и эту таблицу. Но это слишком громоздкий и чреватый ошибками способ.
Так как же можно сделать это лучше?
Использование курсора в хранимой процедуре
Как вы могли догадаться из названия нынешней статьи, лучшей альтернативой (на мой взгляд) для решения этой проблемы является использование курсора в хранимой процедуре.
Давайте создадим в MySQL Workbench первую ХП:
DELIMITER $$
CREATE DEFINER=`root`@`localhost` PROCEDURE `streak`(in cur_year int, out longeststreak int, out status char(1))
BEGIN
declare current_win char(1);
declare current_streak int;
declare current_status char (1);
declare cur cursor for select winlose from lakers where year=cur_year and winlose<>'' order by id desc;
set current_streak=0;
open cur;
fetch cur into current_win;
set current_streak = current_streak +1;
start_loop: loop
fetch cur into current_status;
if current_status <> current_win then
leave start_loop;
else
set current_streak=current_streak+1;
end if;
end loop;
close cur;
select current_streak into longeststreak;
select current_win into `status`;
END
В этой ХП у нас есть один входящий параметр и два исходящих. Это определяет подпись ХП.
В теле ХП мы также объявили несколько локальных переменных для серии результатов (выигрышей или проигрышей, current_win), текущей серии и текущего статуса выигрыш /проигрыш конкретного матча:
declare cur cursor for select winlose from lakers where year=cur_year and winlose'' order by id desc;
Эта строка является объявлением курсора. Мы объявили курсор с именем cur и набор данных, связанных с этим курсором, который является статусом победа /поражение для тех матчей (значение столбца winlose может быть либо «W», либо «L», но не пустое) в конкретном году, которые упорядочены по идентификатору id (последние сыгранные игры будут иметь более высокий ID) в порядке убывания.
Хотя это не видно наглядно, но мы можем себе представить, что этот набор данных будет содержать последовательность значений «L» и «W». На основании данных, приведенных на рисунке 2, она должна быть следующей: «LLLLLLWLL...» (6 значений «L», 1 «W» и т.д.)
Для расчета количества побед / поражений подряд мы начинаем с последнего (и первого в приведенном наборе данных) матча. Когда курсор открыт, он всегда начинается с первой записи в соответствующем наборе данных.
После того, как первые данные загружены, курсор перемещается к следующей записи. Таким образом, поведение курсора похоже на очередь, перебирающую набор данных по системе FIFO (First In First Out). Это именно то, что нам нужно.
После получения текущего статуса победа / поражение и количества последовательных одинаковых элементов в наборе, мы продолжаем обрабатывать по циклу (перебирать) оставшуюся часть набора данных. В каждой итерации цикла курсор будет «переходить» на следующую запись, пока мы не разорвем цикл или пока все записи не будут перебраны.
Если статус следующей записи такой же, как у текущего последовательного набора побед / поражений, это означает, что серия продолжается, тогда мы увеличиваем количество последовательных побед (или поражений) еще на 1 и продолжаем перебирать данные.
Если статус отличается, это означает, что серия прервана, и мы можем остановить цикл. Наконец, мы закрываем курсор и оставляем исходные данные. После этого выводится результат.
Далее мы можем повысить контроль доступа ХП, как это описано в моей предыдущей статье.
Чтобы проверить работу этой ХП, мы можем написать короткий PHP-скрипт:
<?php
$dbms = 'mysql';
$host = 'localhost';
$db = 'sitepoint';
$user = 'root';
$pass = 'your_pass_here';
$dsn = "$dbms:host=$host;dbname=$db";
$cn=new PDO($dsn, $user, $pass);
$cn->exec('call streak(2013, @longeststreak, @status)');
$res=$cn->query('select @longeststreak, @status')->fetchAll();
var_dump($res); //Dump the output here to get a raw view of the output
$win=$res[0]['@status']='L'?'Loss':'Win';
$streak=$res[0]['@longeststreak'];
echo "Lakers is now $streak consecutive $win.n";
Результат обработки должен выглядеть приблизительно так, как показано на следующем рисунке:

(Этот результат основан на данных по играм «Лейкерс» по состоянию на 15 января 2014 года).
Вывод набора данных из хранимой процедуры
Несколько раз по ходу этой статьи разговор касался того, как вывести набор данных из ХП, которая составляет набор данных из результатов обработки нескольких последовательных вызовов другой ХП.
Пользователь может захотеть получить с помощью ранее созданной нами ХП больше информации, чем просто непрерывная серия побед / поражений за год; например мы можем сформировать таблицу, в которой будут выводиться серии побед /поражений за разные годы:
YEAR | Win/Lose | Streak |
2013 | L | 6 |
2012 | L | 4 |
2011 | L | 2 |
(В принципе, более полезной информацией будет длительность самой длинной серии побед или поражений в определенном сезоне. Для решения этой задачи можно легко расширить описанную ХП, поэтому я оставлю эту задачу тем читателям, кому это будет интересно. В рамках текущей статьи мы продолжим обработку текущей серии побед / поражений).
Хранимые процедуры MySQL могут возвращать только скалярные значения (целое число, строку, и т.д.), в отличие от операторов select... from... (результаты преобразуются в набор данных). Проблема в том, что таблица, в которой мы хотим получить результаты, в существующей структуре базы данных не существует, она составляется из результатов обработки хранимой процедуры.
Для решения этой проблемы нам нужна временная таблица или, если это возможно и необходимо, резервная таблица. Давайте посмотрим, как мы можем решить имеющуюся задачу с помощью временной таблицы.
Сначала мы создадим вторую ХП, код которой показан ниже:
DELIMITER $$
CREATE DEFINER=`root`@`%` PROCEDURE `yearly_streak`()
begin
declare cur_year, max_year, min_year int;
select max(year), min(year) from lakers into max_year, min_year;
DROP TEMPORARY TABLE IF EXISTS yearly_streak;
CREATE TEMPORARY TABLE yearly_streak (season int, streak int, win char(1));
set cur_year=max_year;
year_loop: loop
if cur_year<min_year then
leave year_loop;
end if;
call streak(cur_year, @l, @s);
insert into yearly_streak values (cur_year, @l, @s);
set cur_year=cur_year-1;
end loop;
select * from yearly_streak;
DROP TEMPORARY TABLE IF EXISTS yearly_streak;
END
Несколько существенных замечаний к приведенному выше коду:
- Мы определяем самый ранний и самый поздний года для выборки из таблицы lakers;
- Мы создаем временную таблицу для хранения исходящих данных с необходимой структурой (season, streak, win);
- В цикле мы сначала выполняем ранее созданную ХП с необходимыми параметрами (call streak(cur_year, @l, @s);), затем захватываем возвращаемые данные и вставляем их во временную таблицу (insert into yearly_streak values (cur_year, @l, @s););
- Наконец, мы выбираем из временной таблицы и возвращаем набор данных, после чего делаем некоторую настройку (DROP TEMPORARY TABLE IF EXISTS yearly_streak;).
Чтобы получить результаты, мы создаем еще один небольшой PHP-скрипт, код которого показан ниже:
<?php
... // Here goes the db connection parameters
$cn=new PDO($dsn, $user, $pass);
$res=$cn->query('call yearly_streak')->fetchAll();
foreach ($res as $r)
{
echo sprintf("In year %d, the longest W/L streaks is %d %sn", $r['season'], $r['streak'], $r['win']);
}
Выведенные на экран результаты будут выглядеть приблизительно следующим образом:

Обратите внимание, что приведенный выше способ немного отличается от вызова нашей первой ХП.
Первая ХП не возвращает набор данных - только два параметра. В этом случае мы используем PDO exec, а затем query для вывода данных; во второй ХП, мы выводим через нее набор данных, поэтому мы используем PDO query непосредственно через вызов в ХП.
Вуаля! Мы сделали это!
Заключение
В этой статье мы продолжили изучение хранимых процедур MySQL и рассмотрели применение курсоров. Мы рассказали, как извлечь скалярные данные с помощью выходных параметров (определяемых как out var_name vartype в объявлении ХП), а также как выводить результативный набор данных через временную таблицу. По ходу данной статьи мы также коснулись различных вариантов применения операторов в хранимых процедурах.
На сайте MySQL вы можете найти официальную документацию по синтаксису хранимой процедуры и различным операторам. С материалами по созданию хранимой процедуры вы можете ознакомиться в этом документе, а еще больше информации для более глубокого понимания данной темы вы можете найти здесь.
Не стесняйтесь оставлять комментарии, пишите, что вы думаете по этому поводу!