2009-03-21

Мониторинг баз данных Firebird

Firebird 2.0 на полную катушку

Мониторинг баз данных Firebird.

0.Преамбула

  1. Автор не претендует на роль истины в последней инстанции. Задачи и методы их решения, изглагаемые в статье, являются следствием опыта, полученного автором при написании программ, не являются панацеей и если кто-нибудь может предложить более оптимальное решение и поделиться им с нами, автор будет только рад :)
  2. Автор является сторонником движения OpenSource и исповедует один из основных лозунгов OpenSource "Знания человека принадлежат миру", и поэтому Вы вправе свободно копировать, цитировать и использовать данный текст по своему усмотрению. Никаких ограничений на данный текст, изложенные в нем материалы, алгоритмы и методики, не накладывается.

1.Введение

  По администрированию баз даннных InterBase/Firebird написано достаточно много публикаций в основном касающихся Service API (API-функций по управлению базами данных и SQL-сервером), мы не будем останавливаться на них. Целью данной статьи является рассмотрение новых функциональных особенностей Firebird, которые появились в этом SQL-сервере в версии 2.1. Это так называемые "Виртуальные таблицы" или системные таблицы с префиксом MON$ в имени. "Виртуальными", согласно документации, их назвали потому, что они не существуют в базе данных, просто API-функции доступа к базе данных эмулируют присутствие этих таблиц, а служебная информация о текущих процессах в базе данных, располагается в соотвествующих столбцах этих таблиц. На мой взгляд такой подход со стороны разработчиков к реализации мониторинга баз данных является самым удобным, так как не требует разработки дополнительного API-интерфейса (и, соответственно, переработки клиентских VCL-библиотек доступа), а доступ к функциональности можно осуществлять при помощи старых VCL-библиотек (IBX, IBO, FIBPlus), для этого надо лишь поменять gds32.dll(fbclient.dll) на соответствующую текущей версии сервера.

  Многие разработчики сталкивались с отладкой SQL-скриптов, выполняющих какие-либо продолжительные действия или строящих сложные отчеты. И не всегда, к сожалению, написать скрипт получается с первого раза, в некоторых случаях он приводит к зависанию процесса и "торможению" остальных процессов SQL-сервера (имеется в виду архитектура Classic Server). Казалось, нет ничего проще: узнать зависший процесс в диспетчере задач в Windows или вызвав ps ax в LINUX и прибить зависший процесс. Но если в этот момент к базе данных еще подключено 50-70 пользователей (причем у всех работа начинает дико тормозить, шеф уже полез в сейф за пистолетом :), определить кто есть кто в этом списке достаточно проблематично. Нужно лезть в монитор файерволла, смотреть какой PID процесса подключенного от твоего компьютера, потом убивать этот процесс. Не спешите кидать в автора камни с криком "Кто же отлаживает скрипты на реальной базе ?". Отлаживают, причем все, только не признаются в этом. :) А если серьезно, то существует иногда необходимость при разработке систем реального времени или отчетов реального времени, обновляющихся с определенным интервалом, производить отладку на реальных данных потому, что требуется обеспечить ввод этих самых данных с нужной частотой. И обеспечить его лучше, чем это могут сделать сами пользователи системы, вряд ли получиться.

  Или, в конце концов, выяснить, какой из клиентов "завис" и принудительно завершить его запрос. Особенно это полезно в тех случаях, когда главный бухгалтер вдруг решит составить годовой отчет в конце рабочего дня, а не в положенное ей штатным расписанием утреннее время, в то время как идет массовая "набивка" накладных и "подгрузка" данных из распределенных филиалов.

  Давней мечтой программистов баз данных InterBase/Firebird был функционал мониторинга активных транзакций и запросов с возможностью принудительного завершения последних. И вот наконец дождались: разработчики Firebird реализовали это возможность в Firebird v2.1. Об этой возможности далее и пойдет речь в статье.


2.Постановка учебной задачи

  В качестве учебной задачи мы поставим написание программы для мониторинга базы данных при помощи системных таблиц мониторинга, а также управление "зависшими" запросами, в среде Borland Delphi 7.


3.Ограничения учебной задачи

  При решении учебной задачи использовались:
  1. Firebird SQL Server v2.1.0.15972 Classic Edition
  2. Утилита администрирования базами данных InterBase/Firebird IBExpert v2007.05.03
  3. Компилятор языка Паскаль Delphi v7
  4. Библиотека доступа к базам данных InterBase/Firebird InterbaseExpress, входящая в поставку компилятора Delphi 7.
  5. Библиотека EhLib, компонент TDBGridEh для отображения данных. Хотя, в принципе, подойдет и любой другой DBGrid.

  С целью упрощения кода для восприятия, все действия и методы размещены в обработчиках событий OnClick, но это не является основанием для того, чтобы преждевременно записывать автора в ряды "баттоно-кидателей" :)

4.Небольшое отступление

  Прежде, чем приступить к рассмотрению данной функциональности Firebird, стоит обратить внимание на то,что функциональные особенности, описанные в статье, являются нововведением Firebird v2.1, то есть Вам следует обновить версию сервера до указанной. Более того, как замечено на практике, таблицы мониторинга MON$* не совсем виртуальные, и напрямую зависят от версии ODS (On Disk Structure) базы данных (требуется ODS 11.1), а это в свою очередь значит, что придется перед установкой новой версии сервера зарезервировать базу данных под старой версией и восстановить из бэкапа под новой.

  В этом месте Вас ожидают еще одни "грабли". Если Вы используете UDF, скомпилированные при помощи M$ Visual C++ (в часности, популярная rFunc http://sourceforge.net/projects/rfunc), то с радостью сообщаю Вам: разработчики перешли на новую версию компилятора, теперь и fbclient.dll, и процессу сервера требуются библиотеки времени выполнения msvcp80.dll и msvcr80.dll, в то время, как rFunc у Вас собрана скорее всего под старыми msvcp71.dll и msvcr71.dll. А это значит, что даже если у Вас получиться восстановить базу данных из бэкапа, то все равно возможны ошибки при вызове прецедур или триггеров, использующих UDF из библиотек, написанных при помощи младших версий компилятора M$ Visual C++. То есть перед переходом на новую версию сервера, необходимо пересобрать UDF-библиотеки под новой версией компилятора.

  UDF, написанных при помощи FreePascal (Lazarus), Borland Delphi или Borland CBuilder, данное отступление не касается. Опять же, если Вы не используете в Ваших библиотеках линковку с msvcp71.dll и msvcr71.dll

  И последнее. Для того, чтобы использовать данную функциональность Firebird, необходимо заменить клиентские библиотеки Вашего ПО на fbclient.dll версии 2.1, а также для программы учебной задачи, чтобы иметь возможность "убивать" интересующие нас запросы.


5.Описание таблиц мониторинга

  Ниже представлена логическая схема таблиц мониторинга.


  Логической мы ее назовем потому, что связи таблиц, предсталенные на схеме, не являются физическими (не существует соответствующих RELATION CONSTRAINTS), в данном случае эти связи отображают логическое отношение данных, допускающее, например, наличие записей в таблице MON$STATEMENTS, имеющих MON$TRANSACTION_ID = NULL, появляющихся при подключении клиентских приложений, ипользующих fbclient.dll (gds32.dll) версии ниже, чем 2.1. Рассмотрим подробнее эти таблицы:

MON$DATABASE (база данных) имеет одну запись (например, как RDB$DATABASE), хранящую служебную информацию о текущей базе данных. В текущий версии сервера ТОЛЬКО ЧТЕНИЕ
    MON$DATABASE_NAME физическое имя файла базы данных в файловой системе
    MON$PAGE_SIZE размер страницы базы данных (1024, 2048, 4096, 8192, 16384).
    MON$ODS_MAJOR старшая версия ODS базы данных, например 11
    MON$ODS_MINOR младшая версия ODS базы данных, например 1
    MON$OLDEST_TRANSACTION (OIT number)
    MON$OLDEST_ACTIVE (OAT number)
    MON$OLDEST_SNAPSHOT (OST number)
    MON$NEXT_TRANSACTION ID следующей транзакции (значение "генератора транзакций")
    MON$PAGE_BUFFERSколичество страниц, расположенных в кэше
    MON$SQL_DIALECT диалект языка SQL
    MON$SHUTDOWN_MODE текущий режим остановки
        0: online
        1: multi-user shutdown
        2: single-user shutdown
        3: full shutdown
        принимает одно из значений поля RDB$TYPE набора данных, возвращаемых SQL-скриптом
        SELECT T.RDB$TYPE, T.RDB$TYPE_NAME FROM RDB$TYPES T WHERE (T.RDB$FIELD_NAME ='MON$SHUTDOWN_MODE')

    MON$SWEEP_INTERVAL интервал перед сборкой мусора
    MON$READ_ONLY признак доступности базы данных только для чтения
    MON$FORCED_WRITES
    MON$RESERVE_SPACE
    MON$CREATION_DATEдата создания (восстановления из бэкапа) файла базы данных
    MON$PAGES суммарное количество страниц базы данных

MON$ATTACHMENTS (активные подключения) хранит информацию об активных в текущий момент подключениях к базе данных. В текущий версии сервера ТОЛЬКО ЧТЕНИЕ
    MON$ATTACHMENT_ID идентификатор подключения
    MON$SERVER_PID идентификатор процесса сервера (PID). Можно увидеть в диспетчере задач M$ Windows или в списке #ps ax LINUX
    MON$STATEсостояние подключения
        0: idle
        1: active
        принимает одно из значений поля RDB$TYPE набора данных, возвращаемых SQL-скриптом
        SELECT T.RDB$TYPE, T.RDB$TYPE_NAME FROM RDB$TYPES T WHERE (T.RDB$FIELD_NAME ='MON$STATE')

    MON$ATTACHMENT_NAME имя базы, укзанное при подключении (строка подключения). Содержит или физическое имя файла базы данных, или имя псевдонима из файла ($firebird)/aliases.conf, смотря как была задана строка подключения.
    MON$USER имя пользователя, под которым подключились к базе
    MON$ROLE роль пользователя
    MON$REMOTE_PROTOCOL протокол подключения, например TCPv4
    MON$REMOTE_ADDRESS удаленный адрес подлючения, например 192.168.0.101
    MON$REMOTE_PID идентификатор процесса (PID) клиентского приложения на удаленной ЭВМ, вызвавшего подключение. Если клиентское приложение использует библиотеку fbclient.dll младше версии 2.1, то поле содержит NULL
    MON$REMOTE_PROCESS полный путь к исполняемому файлу клиентского приложения на удаленной ЭВМ, вызвавшего подключение. Если клиентское приложение использует библиотеку fbclient.dll младше версии 2.1, то поле содержит NULL
    MON$CHARACTER_SET_ID идентификатор кодировки, указанной при подключении, например 52
        принимает одно из значений поля RDB$CHARACTER_SET_ID набора данных, возвращаемых SQL-скриптом
        SELECT C.RDB$CHARACTER_SET_ID, C.RDB$CHARACTER_SET_NAME FROM RDB$CHARACTER_SETS C

    MON$TIMESTAMP дата-время старта подключения
    MON$GARBAGE_COLLECTION флаг сборки мусора

MON$TRANSACTIONS (активные транзакции) хранит информацию об активных в текущий момент транзакциях. В текущий версии сервера ТОЛЬКО ЧТЕНИЕ
    MON$TRANSACTION_ID идентификатор транзакции
    MON$ATTACHMENT_ID идентификатор подключения из таблицы MON$ATTACHMENTS
    MON$STATE состояние транзакции
        0: idle
        1: active
        принимает одно из значений поля RDB$TYPE набора данных, возвращаемых SQL-скриптом
        SELECT T.RDB$TYPE, T.RDB$TYPE_NAME FROM RDB$TYPES T WHERE (T.RDB$FIELD_NAME ='MON$STATE')

    MON$TIMESTAMP время старта транзакции
    MON$TOP_TRANSACTION (top transaction)
    MON$OLDEST_TRANSACTION (local OIT number)
    MON$OLDEST_ACTIVE (local OAT number)
    MON$ISOLATION_MODE уровень изоляции транзакции
        0: consistency
        1: concurrency
        2: read committed record version
        3: read committed no record version
        принимает одно из значений поля RDB$TYPE набора данных, возвращаемых SQL-скриптом
        SELECT T.RDB$TYPE, T.RDB$TYPE_NAME FROM RDB$TYPES T WHERE (T.RDB$FIELD_NAME ='MON$ISOLATION_MODE')

    MON$LOCK_TIMEOUT таймаут блокировки записей
        -1: infinite wait (бесконечно)
        0: no wait (не ожидать разрешения конфликта)
        N: timeout N (ожидать разрешение конфликта)
    MON$READ_ONLY транзакция открыла набор данных "только для чтения"
    MON$AUTO_COMMIT признак автоматического подтверждения транзакции
    MON$AUTO_UNDO признак автоматического отката транзакции

MON$STATEMENTS (активные запросы) хранит информацию об активных в текущий момент запросах к базе данных. В текущий версии сервера ТОЛЬКО ЧТЕНИЕ для записей, вызванных подключением при помощи старых версий клиентских библиотек, и ЧТЕНИЕ-УДАЛЕНИЕ для подключений, использующих клиентскую библиотеку версии 2.1 и старше.
    MON$STATEMENT_ID идентификатор запроса к базе данных.
    MON$ATTACHMENT_ID идентификатор подключения к базе данных из таблицы MON$ATTACHMENTS
    MON$TRANSACTION_ID идентификатор транзакции. Если клиентское приложение использует библиотеку fbclient.dll младше версии 2.1, то поле содержит NULL
    MON$STATE состояние запроса
        0: idle
        1: active
        принимает одно из значений поля RDB$TYPE набора данных, возвращаемых SQL-скриптом
        SELECT T.RDB$TYPE, T.RDB$TYPE_NAME FROM RDB$TYPES T WHERE (T.RDB$FIELD_NAME ='MON$STATE')

    MON$TIMESTAMP дата-время старта запроса
    MON$SQL_TEXT текст запроса (если доступен). для операторов DDL (CREATE TABLE, VIEW, TRIGGER) не доступен.


5. Написание клиентского приложения.

  Создадим новый проект в Delphi и разместим компоненты как показано на рисунке.

  

  Назначение компонентов: IBDataBase1 создает подключение к базе данных, trAttachments :TIBTransaction - транзакция для наборов данных, qrAttachments :TIBQuery для выборки данных о текущих подключениях. qrTransactions :TIBQuery выбирает активные транзакции Firebird2.1-клиентов. qrTransactionsStatements :TIBQuery выбирает активные запросы Firebird2.1-клиентов, qsStatements :TIBQuery выбирает запросы ранних клиентов, для которых MON$TRANSACTION_ID = NULL.

  Для класса формы объявим такие методы в секции private.

//Обновляет список подключений к базе данных 
procedure TFormMain.RefreshAttachments;
begin
  if trAttachments.InTransaction then
    trAttachments.Rollback;
  qrAttachments.SQL.Text :=
    'SELECT MA.MON$ATTACHMENT_ID, MA.MON$SERVER_PID, MA.MON$STATE, '+#13#10+
    '       (SELECT FIRST 1 T.RDB$TYPE_NAME FROM RDB$TYPES T '+#13#10+
    '        WHERE (T.RDB$FIELD_NAME = ''MON$STATE'') '+#13#10+
    '          AND (T.RDB$TYPE       = MA.MON$STATE)) AS MON$STATE_TYPE_NAME, '+#13#10+
    '       MA.MON$ATTACHMENT_NAME, MA.MON$USER, MA.MON$ROLE, MA.MON$REMOTE_PROTOCOL, '+#13#10+
    '       MA.MON$REMOTE_ADDRESS, MA.MON$REMOTE_PID, MA.MON$CHARACTER_SET_ID, '+#13#10+
    '       (SELECT FIRST 1 CH.RDB$CHARACTER_SET_NAME FROM RDB$CHARACTER_SETS CH '+#13#10+
    '        WHERE (CH.RDB$CHARACTER_SET_ID = MA.MON$CHARACTER_SET_ID)) AS CHARACTER_SET_NAME, '+#13#10+
    '       MA.MON$TIMESTAMP, MA.MON$GARBAGE_COLLECTION '+#13#10+
    'FROM MON$ATTACHMENTS MA '+#13#10;
  // если отмечена "Не показывать текущее подключение ...", то исключаем из набора данных текущее подключение к базе данных   
  if cbxHideCurrentConnection.Checked then
    qrAttachments.SQL.Text := qrAttachments.SQL.Text + 'WHERE (MA.MON$ATTACHMENT_ID <> CURRENT_CONNECTION) ';
  try
    qrAttachments.Open;
  except
  end;
end;

//Обновляет список активных транзакций для указанного подключения AttachmentId
procedure TFormMain.RefreshTransactions(const AttachmentId:string);
begin
  if qrTransactions.Active then qrTransactions.Close;
  qrTransactions.SQL.Text :=
    'SELECT TR.MON$TRANSACTION_ID, TR.MON$ATTACHMENT_ID, TR.MON$STATE, '+#13#10+
    '       (SELECT FIRST 1 T.RDB$TYPE_NAME FROM RDB$TYPES T '+#13#10+
    '        WHERE (T.RDB$FIELD_NAME = ''MON$STATE'') '+#13#10+
    '          AND (T.RDB$TYPE       = tr.MON$STATE)) AS MON$STATE_TYPE_NAME, '+#13#10+
    '       TR.MON$TIMESTAMP, TR.MON$TOP_TRANSACTION, TR.MON$OLDEST_TRANSACTION, '+#13#10+
    '       TR.MON$OLDEST_ACTIVE, TR.MON$ISOLATION_MODE, '+#13#10+
    '       (SELECT FIRST 1 T.RDB$TYPE_NAME FROM RDB$TYPES T '+#13#10+
    '        WHERE (T.RDB$FIELD_NAME = ''MON$ISOLATION_MODE'') '+#13#10+
    '          AND (T.RDB$TYPE       = tr.MON$ISOLATION_MODE)) AS MON$ISOLATION_MODE_NAME, '+#13#10+
    '       TR.MON$LOCK_TIMEOUT, TR.MON$READ_ONLY, TR.MON$AUTO_COMMIT, TR.MON$AUTO_UNDO '+#13#10+
    'FROM   MON$TRANSACTIONS TR '+#13#10+
    'WHERE  (TR.MON$ATTACHMENT_ID = '''+AttachmentId+''') '+#13#10+
    '  AND  (TR.MON$TRANSACTION_ID <> CURRENT_TRANSACTION)'; // hidding current transaction
  try
    qrTransactions.Open;
  except
  end;
end;

// Обновляет список активных запросов Firebird2.1-клиентов для транзакции TransactionId подключения AttachmentId
procedure TFormMain.RefreshTransactionsStatments(const AttachmentId, TransactionId:string);
begin
  if qrTransactionsStatments.Active then
    qrTransactionsStatments.Close;
  qrTransactionsStatments.SQL.Text :=
    'SELECT ST.MON$STATEMENT_ID, ST.MON$ATTACHMENT_ID, ST.MON$TRANSACTION_ID, '+#13#10+
    '       ST.MON$STATE, ST.MON$TIMESTAMP, ST.MON$SQL_TEXT, '+#13#10+
    '       (SELECT FIRST 1 T.RDB$TYPE_NAME FROM RDB$TYPES T '+#13#10+
    '        WHERE (T.RDB$FIELD_NAME = ''MON$STATE'') '+#13#10+
    '          AND (T.RDB$TYPE       = ST.MON$STATE)) AS MON$STATE_TYPE_NAME '+#13#10+
    'FROM   MON$STATEMENTS ST '+#13#10+
    'WHERE  (ST.MON$ATTACHMENT_ID = '''+AttachmentId+''') '+#13#10+
    '  AND  (ST.MON$TRANSACTION_ID = '''+TransactionId+''')';
  try
    qrTransactionsStatments.Open;
  except
  end;
end;

Обновляет список активных запросов ранних Firebird-клиентов для подключения AttachmentId
procedure TFormMain.RefreshStatments(const AttachmentId:string);
begin
  if qrStatements.Active then
    qrStatements.Close;
  qrStatements.SQL.Text :=
    'SELECT ST.MON$STATEMENT_ID, ST.MON$ATTACHMENT_ID, ST.MON$TRANSACTION_ID, '+#13#10+
    '       ST.MON$STATE, ST.MON$TIMESTAMP, ST.MON$SQL_TEXT, '+#13#10+
    '       (SELECT FIRST 1 T.RDB$TYPE_NAME FROM RDB$TYPES T '+#13#10+
    '        WHERE (T.RDB$FIELD_NAME = ''MON$STATE'') '+#13#10+
    '          AND (T.RDB$TYPE       = ST.MON$STATE)) AS MON$STATE_TYPE_NAME '+#13#10+
    'FROM   MON$STATEMENTS ST '+#13#10+
    'WHERE  (ST.MON$ATTACHMENT_ID = '''+AttachmentId+''') '+#13#10+
    '  AND  (ST.MON$TRANSACTION_ID IS NULL)';
  try
    qrStatements.Open;
  except
  end;
end;

// Убивает активный запрос по его коду StatementId
procedure TFormMain.KillStatement(const StatementId:string);
var
  lk_tr :TIBTransaction;
  lk_qr :TIBQuery;
  lk_flag :Boolean;
begin
  if trAttachments.InTransaction then
    trAttachments.Rollback;

  lk_tr := TIBTransaction.Create(nil);
  lk_tr.DefaultDatabase := IBDataBase1;
  lk_qr := TIBQuery.Create(lk_tr);
  lk_qr.Database    := IBDataBase1;
  lk_qr.Transaction := lk_tr;
  lk_qr.SQL.Text := 'DELETE FROM MON$STATEMENTS MS WHERE (MS.MON$STATEMENT_ID = '+StatementId+')';
  lk_flag := false;
  lk_tr.StartTransaction;
  try
    lk_qr.ExecSQL;
  finally
    lk_flag := true;
  end;
  if lk_tr.InTransaction then
    begin
    if lk_flag then
      lk_tr.Commit
     else
      lk_tr.Rollback;
    end;
  lk_tr.Free;
  RefreshAttachments;
end;


  Соответственно, определим следующие обработчики событий:
procedure TFormMain.ButtonRefreshClick(Sender: TObject);
begin
  RefreshAttachments;
end;

procedure TFormMain.qrAttachmentsBeforeClose(DataSet: TDataSet);
begin
  if qrTransactions.Active then
    qrTransactions.Close;
  if qrStatements.Active then
    qrStatements.Close;
end;

procedure TFormMain.qrAttachmentsAfterScroll(DataSet: TDataSet);
begin
  RefreshTransactions(DataSet.FieldByName('MON$ATTACHMENT_ID').AsString);
  RefreshStatments   (DataSet.FieldByName('MON$ATTACHMENT_ID').AsString);
end;

procedure TFormMain.qrTransactionsBeforeClose(DataSet: TDataSet);
begin
  if qrTransactionsStatments.Active then
    qrTransactionsStatments.Close;
end;

procedure TFormMain.qrTransactionsAfterScroll(DataSet: TDataSet);
begin
  RefreshTransactionsStatments(DataSet.FieldByName('MON$ATTACHMENT_ID').AsString,
    DataSet.FieldByName('MON$TRANSACTION_ID').AsString);
end;

procedure TFormMain.ButtonKillClick(Sender: TObject);
begin
  KillStatement(qrTransactionsStatments.FieldByName('MON$STATEMENT_ID').AsString);
end;


  Запустив программу на выполнение, мы можем увидеть состояние базы данных.

  

  


6.Заключение

  Данная статья была нацелена на то, чтобы показать как пользоваться таблицами мониторинга в базах данных Firebird v2.1, поэтому автор старался сделать все "по-примитивней". Конечно, автор надеется, что у читателя найдется более широкая область применения данной функциональности для своих нужд, например как: использование мониторинга в связке с потоковыми запросами (представлеными в стандартных примерах $DELPHI\Demos\DelphiWin32\VCLWin32\Db\IBX\ThreadedIBX), отслеживание атак на Web-системы (Apache->PHP->Firebird), отладка сложных запросов.

Создание перекрестных запросов (Cross-Tab)

Firebird 2 на полную катушку
Часть 1. Создание перекрестных запросов Cross-Tab).
0.Преамбула
1. Автор не претендует на роль истины в последней инстанции. Задачи и методы их решения, изглагаемые в статье, являются следствием опыта, полученного автором при написании программ, не являются панацеей и если кто-нибудь может предложить более оптимальное решение и поделиться им с нами, автор будет только рад :) 2. Автор является сторонником движения OpenSource и исповедует один из основных лозунгов OpenSource "Знания человека принадлежат миру", и поэтому Вы вправе свободно копировать, цитировать и использовать данный текст по своему усмотрению. Никаких ограничений на данный текст, изложенные в нем материалы, алгоритмы и методики, не накладывается.
1.Введение
В практическом написании программ для работы с базами данных часто возникает необходимость в отображении данных информационной системы, основанных на выполнении перекрестных запросов (Cross-Tab) к объектам базы данных. Перекрестным запросом в данном случае мы договримся называть набор данных, основанный на выполнении запросов к нескольким объектам базы данных, когда одно из измерений запроса требуется "развернуть" из записей набора данных в столбцы. Практических примеров таких запросов можно привести достаточно много: отчет "шахматка" в бухгалтерском учете, отчет "табель выходов" в подсистеме управления персоналом, журнал-ордер "Расходы по элементам" в финансовом учете, отчет "суммарные остатки ТМЦ по местам хранения". Иными словами, это такой набор данных, строки которого являются выборкой по какому-либо объекту базы данных, столбцы - выборкой по другому объекту, а пересечение строк и столбцов содержит необходимые расчетные значения. В силу ряда причин в своей повседневной рутине автор использует Firebird SQL Server, как основную СУБД для своих программ, поэтому дальше в статье речь пойдет исключительно об этом SQL-сервере, хотя автор надеется, что данный опыт может быть натолкнет читателей на мысли о том, как применить эти методики в других СУБД. Основной проблематикой решения Croos-Tab задач в рамках SQL-серверов семейства Interbase/Firebird/Yaffil было то, что логическим решением задачи был набор данных с переменным числом столбцов. И если сформировать тривиальный запрос SELECT не составляло труда, то со сложной бизнес-логикой (например, та же "шахматка") возникали проблемы. Как решение проблем с организацией бизнес-логики в базах данных InterBase/Firebird был разработан механизм триггеров и хранимых процедур, но тут возникала следующая трудность: хранимая процедура не может иметь переменное число столбцов. Автор сам сталкивался с решением задач данного типа и повседневная практика требовала их быстрого решения. Выходы из ситуации находились, например путем "статического" написания столбцов в хранимой процедуре. И, когда менялось заполнение одного из справочников по которому делался "разворот" в Cross-Tab, требовалось хранимую процедуру переделывать. Недостаком данного подхода являлось то, что данные и логика "разворота" в CrossTab были жестко зашиты в хранимую процедуру и, несмотря на то, что справочник редко изменялся, все же иногда приходилось переделывать некоторые формы или отчеты, например в связи с открытием нового филила и, соответственно, добавлением нового места хранения ТМЦ. Один знакомый автора, тоже занимающийся разработкой СУБД на основе InterBase, нашел следующий выход: в рамках одной транзакции создавалась хранимая процедура, делалась выборка данных из хранимой процедуры, строился отчет, а затем транзакции делался откат. В результате хранимай процедура "не сохранялась". Кстати, его система работает уже несколько лет, объем данных - около 1Гб. (Эй, кто там говорил, что в InterBase нельзя играться метаданными "на лету" ?). Преимуществом данного метода является отсутствие необходимости переделывать логику или алгоритмы программы при изменении наполнения справочников. К недостаткам, пожалуй, стоит отнести то, что разработчики не рекомендуют играться с метаданными на лету. Так было до выхода Firebrid 2.0. Во второй версии этого SQL-сервера появились так называемые динамические хранимые процедуры или хранимые процедуры без имени, описываемые конструкицей языка SQL EXECUTE BLOCK. Чуть ранее в Firebird была реализована возможность инициализировать параметры хранимой процедуры перед выполнением значениями по-умолчанию. Именно об этих двух новшествах далее пойдет речь.
2.Постановка учебной задачи
Путь у нас имеется две таблицы справочники - TABL$R_TMC ("Товарно-материальные ценности") и TABL$R_PLACE ("Места хранения"), таблица регистр TABL$P_TMC_QUANT("Регистр остатков по местам хранения"). Структура таблиц и их связей представлена ниже на рисунке:
Содержание таблицы TABL$R_TMC - номенклатурные названия ТМЦ, хранящихся на складе. В таблице TABL$R_PLACE содержаться названия мест хранения ("Основной склад", "Витрина в офисе", "Производство"). Регистр остатков TABL$P_TMC_QUANT содержит записи об остатках ТМЦ в данном месте хранения. Естественно, требуется организовать автоматическое заполнение регистра остатков нулями при изменении значений справочников (добавлении новых записей), например реализовав этот механизм в триггерах таблиц справочников. Мы не будем подробно останавливаться на этом моменте. Требуется построить Corss-Tab отчет об остатках ТМЦ по местам хранения и общим остатком в следующем виде:
так как по мнению заказчика он гораздо удобнее в анализе данных.
3.Ограничения учебной задачи
При решении учебной задачи использовались: 1. Firebird SQL Server v2.0.1.12855 2. Утилита администрирования базами данных InterBase/Firebird IBExpert v2007.05.03 3. Компилятор языка Паскаль Delphi v7 4. Библиотека доступа к базам данных InterBase/Firebird InterbaseExpress, входящая в поставку компилятора Delphi 7. 5. Библиотека доступа к базам данных InterBase/Firebird FIBPlus (Demo version). 6. Библиотека EhLib, компонент TDBGridEh для отображения данных. Хотя, в принципе, подойдет и любой другой DBGrid.
4.Тривиальное решение задачи.
Под тривиальным решением мы будем понимать самый простой способ получить требуемый набор данных. Давайте рассмотрим подробнее, что нам требуется: 1) Выбрать элементы из таблицы-справочника ТМЦ; 2) Для каждого из элементов выбрать элементы со справочника "Места хранения". 3) Выбрать из регистра остатков отстаки ТМЦ в данном месте хранения. Создадим новый проект в Delphi и разместим на нем компоненты, как показано на рисунке:
Допустим, форма, изображенная на рисунке, будет называться FormMain :TFormMain = class(TForm). Компонент IBDataBase будет обеспечивать поключение к базе данных, компоненты trRead->IBQuery->DataSource->DBGridEh будут служить для отображения требуемого набора данных, компоненты TrTemp и QrTemp для временного выполнения запросов. Конечно, удобнее и правильней, создавать компоненты для временного выполнения запросов в RunTime, но мы исключим этот момент, чтобы излишне не нагромождать код. Создадим также в секции private два метода procedure RefreshFB15; и procedure RefreshFB20; которые будут формировать SQL-скрипт для выборки данных на Firebird v1.5 и v2.0 соответсвенно. В обрабочике события ButtonRefresh.OnClick мы будем вызывать один из этих методов соответсвенно. Итак, пусть у нас в распоряжении имеется Firebird версии "полтора". Получить требуемый набор данных мы можем при помощи простого оператора SELECT, где будет выбираться наимнования из справочника ТМЦ и для каждого из них будет выполняться SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT для каждого из мест хранения. В общем виде оператор SELECT можно представить следующим образом: SELECT TMC.NAME, (SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '1')) AS QUANT_1 ............ (SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = 'N')) AS QUANT_N FROM TABL$R_TMC TMC .... Конечно, прописывать скрипт статически не имеет смысла, так как справочник "Места хранения" может меняться с течением времени, поэтому мы будем формировать его на лету в цикле перебора элементов справочника. В добавок, есть возможность убить двух зайцев: сформировать скрипт и колонки в DBGrid-е. Ниже представлен листинг процедуры, выполняющей эти дейсвия.

procedure TFormMain.RefreshFB15;
var
  lk_Col    :TColumnEh;
  lk_sql    :string;
  lk_FldName:string;
const
  FldPrfx  = 'QNT';  // префикс названия столбцов в результирующем наборе данных;
begin
  if not IBDatabase.Connected then exit;
  // откатываем траназакцию отображения данных и соответсвенно, закрываем наборы данных
  if trRead.InTransaction then trRead.Rollback;
  // скрываем DBGridEh, чтобы не "моргал" при создании колонок
  DBGridEh.Visible    := false;
  DBGridEh.FrozenCols := 0;
  // очищаем колонки
  DBGridEh.Columns.Clear;
  // создаем первую колонку
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.FieldName     := 'TMC_NAME';
  lk_Col.Title.Caption := 'ТМЦ|Наименование';
  lk_Col.Width         := 320;
  lk_Col.Tag           := 666;
  lk_Col.Footer.ValueType := fvtStaticText;
  lk_Col.Footer.Alignment := taLeftJustify;
  lk_Col.Footer.Value     := 'ИТОГО';
  // выполняем запрос к справочнику "места хранения"
  if TrTemp.InTransaction then TrTemp.Rollback;
  TrTemp.StartTransaction;
  QrTemp.SQL.Text := 'SELECT T.ID, T.NAME FROM TABL$R_PLACE T ';
  try
    QrTemp.Open;
  except
    DBGridEh.Visible := true;
    exit;
  end;
  // переменная lk_sql будет содержать скрипт выборки данных
  lk_sql := 'SELECT TMC.ID AS TMC_ID, TMC.NAME AS TMC_NAME '+#13#10;
  // цикл перебора элементов справочника "места хранения"
  QrTemp.First;
  while not QrTemp.Eof do
    begin
    // создаем имя "временного" столбца
    lk_FldName := FldPrfx + FormatFloat('000000', QrTemp.FieldByName('ID').AsInteger);
    // добавляем этот стобец в выборку
    lk_sql := lk_sql + '  ,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = '+
      'TMC.ID)AND(TQ.PLACE_ID = '''+QrTemp.FieldByName('ID').AsString+''')) AS '+lk_FldName+'  '+#13#10;
    // добавляем колоку в DBGridEh
    lk_Col               := DBGridEh.Columns.Add;
    lk_Col.FieldName     := lk_FldName;
    lk_Col.DisplayFormat := '# ### ##0';
    lk_Col.Title.Caption := 'Место хранения|'+QrTemp.FieldByName('NAME').AsString;
    lk_Col.Width         := 56;
    lk_Col.Footer.ValueType     := fvtSum;
    lk_Col.Footer.FieldName     := lk_FldName;
    lk_Col.Footer.DisplayFormat := '# ### ##0';
    lk_Col.Footer.Alignment     := taRightJustify;
    QrTemp.Next;
    end;
  // закрываем выборку по справочнику "места хранения"
  if TrTemp.InTransaction then TrTemp.Rollback;
  // добавляем в выборку столбец "ИТОГО"
  lk_FldName := FldPrfx + 'TOTAL';
  lk_sql := lk_sql+'  ,(SELECT SUM(TQ.QUANT) FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = '+
    'TMC.ID)) AS '+lk_FldName+'  '+#13#10;
  // добавляем колонку в DBGridEh
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.DisplayFormat := '# ### ##0';
  lk_Col.FieldName     := lk_FldName;
  lk_Col.Title.Caption := 'ИТОГО';
  lk_Col.Width         := 62;
  lk_Col.Footer.ValueType     := fvtSum;
  lk_Col.Footer.FieldName     := lk_FldName;
  lk_Col.Footer.DisplayFormat := '# ### ##0';
  lk_Col.Footer.Alignment     := taRightJustify;
  // Инициализируем скрипт выборки данных
  IBQuery.SQL.Text := lk_sql+'FROM TABL$R_TMC TMC ORDER BY TMC.NAME; ';
  DBGridEh.FrozenCols  := 1;
  // Показываем DBGridEh
  DBGridEh.Visible     := true;
  // Открываем набор данных
  if not trRead.InTransaction then
    trRead.StartTransaction;
  try
    IBQuery.Open;
  except
  end;
end;
 
В результате выполнения данного метода в объекте IBQuery в свойстве SQL будет следующий скрипт:
SELECT TMC.ID AS TMC_ID, TMC.NAME AS TMC_NAME
,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '1')) AS QNT000001
,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '2')) AS QNT000002
,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '3')) AS QNT000003
,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '4')) AS QNT000004
,(SELECT FIRST 1 TQ.QUANT FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)AND(TQ.PLACE_ID = '5')) AS QNT000005
,(SELECT SUM(TQ.QUANT) FROM TABL$P_TMC_QUANT TQ WHERE(TQ.TMC_ID = TMC.ID)) AS QNTTOTAL
FROM TABL$R_TMC TMC ORDER BY TMC.NAME;
 
При выполнении этой процедуры во время прогона программы мы получим требуемый набор данных:
Таким же образом можно создавать печатные отчеты. Например, используя генератор отчетов FastReport в цикле перебора элементов справочника "Места хранения" можно вместо колонок DBGridEh создавать компоненты TfrxMemoView и прописывать им нужные свойства. Преимущество данного метода состоит в том, что его можно использовать почти для всех версии Firebird и InterBase, поддерживающих конструкцию SELECT FIRST ... (InterBase v6.0 кажется ее не поддерживает). Метод в принципе хорош, но что делать, если выборка в Cross-Tab не является тривиальным SELECT-ом, а сопряжена с некоторой бизнес-логикой, например, как отчет "Расходы по элементам" или "Шахматка" ? Ответ, как бы, напрашивается сам: использовать хранимую процедуру. Как мы оговаривали ранее, хранимые процедуры не могут содержать переменного числа столбцов и тут на выручку приходит конструкция EXECUTE BLOCK. Синтаксис этой конструкции такой же, как и конструкции CREATE PROCEDURE <..NAME..>, но преимущество в том, что она может быть выполнена как тривиальный SELECT-запрос, и следовательно, может быть сформирована на лету. Мы не будем усложнять поставленную задачу дополнительной логикой с целью упрощения исходного кода, "усложнить" дополниетльными условиями и/или вычислениями, думаю, Вы сможете сами. Мы просто договоримся сформировать требуемый набор данных при помощи конструкции EXECUTE BLOCK. Здесь требуется сделать небольшое но важное отступление, которое касается обработки параметров запросов в компонетах Delphi. Дело в том, что в Firebird двоеточие ":" является служебным символом, служащим для передачи значений в параметры в языке хранимых процедур. В компонентах Delphi признаками параметров служат двоеточие и вопросительный знак. Поэтому, если Вы используете в IBQuery запросы конструкции EXECUTE BLOCK, Вам нужно выставить свойство у этого компонента ParamCheck := false. Ниже находится листинг процедуры RefreshFB20, формирующей набор данных на основе конструкции EXECUTE BLOCK

procedure TFormMain.RefreshFB20;
var
  lk_Col    :TColumnEh;
  lk_vars_s :string;
  lk_body_s :string;
  lk_total_s:string;
  lk_FldName:string;
const
  FldPrfx  = 'QNT';
begin
  if not IBDatabase.Connected then exit;
  if trRead.InTransaction then trRead.Rollback;
  DBGridEh.Visible    := false;
  DBGridEh.FrozenCols := 0;
  DBGridEh.Columns.Clear;
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.FieldName     := 'TMC_NAME';
  lk_Col.Title.Caption := 'Òîâàð|Íàèìåíîâàíèå';
  lk_Col.Width         := 320;
  lk_Col.Tag           := 666;
  lk_Col.Footer.ValueType := fvtStaticText;
  lk_Col.Footer.Alignment := taLeftJustify;
  lk_Col.Footer.Value     := 'ÈÃÎÃÎ';
  if TrTemp.InTransaction then TrTemp.Rollback;
  TrTemp.StartTransaction;
  QrTemp.SQL.Text := 'SELECT T.ID, T.NAME FROM TABL$R_PLACE T ';
  try
    QrTemp.Open;
  except
    DBGridEh.Visible := true;
    exit;
  end;
  lk_vars_s := '';
  lk_body_s := '';
  lk_total_s:= '';
  QrTemp.First;
  while not QrTemp.Eof do
    begin
    lk_FldName := FldPrfx + FormatFloat('000000', QrTemp.FieldByName('ID').AsInteger);
    lk_vars_s  := lk_vars_s + '  ,'+lk_FldName+' NUMERIC(15,3) '+#13#10;
    lk_body_s  := lk_body_s +'    SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT '+
      'Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '''+
      QrTemp.FieldByName('ID').AsString+''') INTO :'+lk_FldName+'; '+#13#10;
    lk_total_s := lk_total_s + ' + :'+lk_FldName;
    lk_Col               := DBGridEh.Columns.Add;
    lk_Col.FieldName     := lk_FldName;
    lk_Col.DisplayFormat := '# ### ##0';
    lk_Col.Title.Caption := 'Ìåñòà õðàíåíèÿ|'+QrTemp.FieldByName('NAME').AsString;
    lk_Col.Width         := 56;
    lk_Col.Footer.ValueType     := fvtSum;
    lk_Col.Footer.FieldName     := lk_FldName;
    lk_Col.Footer.DisplayFormat := '# ### ##0';
    lk_Col.Footer.Alignment     := taRightJustify;
    QrTemp.Next;
    end;
  if TrTemp.InTransaction then TrTemp.Rollback;
  lk_FldName := FldPrfx + 'TOTAL';
  lk_vars_s  := lk_vars_s + '  ,'+lk_FldName+' NUMERIC(15,3) '+#13#10;
  lk_body_s  := '    '+lk_FldName+' = 0;'+#13#10+
    lk_body_s + '    '+lk_FldName+' = :'+lk_FldName+lk_total_s+';'+#13#10;
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.DisplayFormat := '# ### ##0';
  lk_Col.FieldName     := lk_FldName;
  lk_Col.Title.Caption := 'Èòîãî';
  lk_Col.Width         := 62;
  lk_Col.Footer.ValueType     := fvtSum;
  lk_Col.Footer.FieldName     := lk_FldName;
  lk_Col.Footer.DisplayFormat := '# ### ##0';
  lk_Col.Footer.Alignment     := taRightJustify;
  IBQuery.SQL.Text :=
    'EXECUTE BLOCK RETURNS( '+#13#10+
    '   TMC_ID   INTEGER '+#13#10+
    '  ,TMC_NAME VARCHAR(255)'+#13#10+
    lk_vars_s+
    ')AS '+#13#10+
    'BEGIN '+#13#10+
    '  FOR SELECT T.ID, T.NAME FROM TABL$R_TMC T ORDER BY T.NAME INTO :TMC_ID, :TMC_NAME DO '+#13#10+
    '    BEGIN '+#13#10+
    lk_body_s+
    '    SUSPEND;'+#13#10+
    '    END '+#13#10+
    'END ';
  DBGridEh.FrozenCols  := 1;
  DBGridEh.Visible     := true;
  if not trRead.InTransaction then
    trRead.StartTransaction;
  try
    IBQuery.Open;
  except
  end;
end;
 
В результате выполнения этой процедуры в компоненте IBQuery в свойстве SQL будет содержаться следующий скрипт:

EXECUTE BLOCK RETURNS(
TMC_ID    INTEGER
,TMC_NAME  VARCHAR(255)
  ,QNT000001 NUMERIC(15,3)
,QNT000002 NUMERIC(15,3)
,QNT000003 NUMERIC(15,3)
,QNT000004 NUMERIC(15,3)
,QNT000005 NUMERIC(15,3)
,QNTTOTAL  NUMERIC(15,3)
)AS
BEGIN
FOR SELECT T.ID, T.NAME FROM TABL$R_TMC T ORDER BY T.NAME INTO :TMC_ID, :TMC_NAME DO
BEGIN
QNTTOTAL = 0;
    SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '1') INTO :QNT000001;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '2') INTO :QNT000002;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '3') INTO :QNT000003;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '4') INTO :QNT000004;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '5') INTO :QNT000005;
QNTTOTAL = :QNTTOTAL + :QNT000001 + :QNT000002 + :QNT000003 + :QNT000004 + :QNT000005;
    SUSPEND;
    END
END
 
Как видно из примера, логика скрипта выборки данных не изменилась, но при запуске приложения мы получаем аналогичный результат, как и в первом примере. Усложнение логики оставим читателю для самостоятельной реализации на его реальных данных.
5.Усложняем посталенную задачу.
Чтобы продемонстрировать еще одну возможность Cross-Tab наборов данных, зададимся целью сделать редактируемый набор данных таким образом, чтобы была возможность редактировать остатки прямо в этой форме. Стоит сказать, конечно, что на практике нельзя давать возможность пользователям редактировать остатки вручную, изменения в регистре остатков должны производится только триггерами при проведении/распроведении соотвествующих документов, которые влияют на остатки. Но для демонстрации такая постановка задачи вполне подойдет. Для того, чтобы сделать редактируемый набор данных, основанный на выборке из нескольких таблиц, обычно пользуются компонентом TIBDataSet, и в часности его свойствами SelectSQL, RefreshSQL и UpdateSQL. Сразу возникает следующий вопрос: если с выборкой данных нет проблем (предыдущий пример), то для изменения данных требуется выполнить сразу несколько SQL-операторов UPDATE, что нельзя выполнить в рамках одного скрипта. Логически напрашивается вывод о необходимости использования хранимой процедуры, в которую в качестве параметров мы будем передвать новые значения полей Cross-Tab. Хранимую процедуру создавать незачем, мы можем сформировать ее на лету так же, как и процедуру выборки данных через конструкцию EXECUTE BLOCK. Возникает вторая проблема, которая поднималась всколзь ранее: обработка параметров запросов в Firebird и в компонентах Delphi, связанная с двоеточием. Общий смысл проблемы состоит в том, что в хранимой процедуре можно инициализировать значения параметров по-умолчанию, чем можно воспользоваться для передачи значений параметров в конструкцию EXECUTE BLOCK в виде PARAM_1 INTEGER = ?Q_PARAM_1, плюс к этому ко всему внутри процедуры мы можем обращаться к значениям параметров через двоеточие, и тут делфячий парсер начинает путаться: где параметры запроса, а где обращение внутри процедуры. Более подробно данная проблема описана в документации к SQL-серверу Firebird ($firebird)/doc/sql.extensions/README.execute_block.txt, в часности здесь сказано, что препроцессор языка SQL клиентского приложения в случае с конструкцией EXECUTE BLOCK должен парсить скрипт только в секции параметров, и "не залазить" в тело процедуры. В общем, Interbase Express для решения данного класса задач не совсем подходит. В принципе, данную проблему можно решить, внеся исправления в исходный код IBX, но я предлагаю четателю не заниматься мазохизмом, в отличие от автора :), а воспользоваться готовым решением от компании DevRace в виде библиотеки FIBPlus, которая поддерживает спецификацию языка SQL для Firebird версии 2 и выше. Скачать демонстрационную версию библиотеки можно с их сайта. Создадим новый проект в Delphi и разместим на нем компоненты, как показано на рисунке:
Назначение компонент, расположенных на главной форме приложения, то же, что и у предыдущего примера. Отличие состоит только в том, что вместо тривиального запроса и компонента IBQuery мы используем компонент pFIBDataSet, чтобы сделать живой набор данных. Также, этот компонент имеет две транзакции trRead и trWrite для чтения и записи соответственно, что позволяет использовать функциональные особенности Firebird в обработке транзакций чтения и транзакций записи в полную силу. Второе, на чем автор хочет остановить внимание, это свойство SQLs компонента pFIBDataSet, которое содержит SQL-скрипты выборки, обновления, изменения и удаления записи. Вот именно эти скрипты мы будем формировать программно. Алгоритм формирования этих свойств в принципе тот же, только мы будем в одном цикле формировать сразу несколько скриптов. Ниже приведен исходный код процедуры Refresh, которую мы будем вызывать в обработчике события ButtonRefresh.OnClick.

procedure TFormMain.RefreshView;
var
  lk_Col    :TColumnEh;
  lk_vars_s :string;
  lk_body_s :string;
  lk_total_s:string;
  lk_vars_u :string;
  lk_body_u :string;
  lk_FldName:string;
const
  FldPrfx  = 'QNT';
begin
  if not FIBDatabase.Connected then exit;
  if trRead.InTransaction then trRead.Rollback;
  DBGridEh.Visible    := false;
  DBGridEh.FrozenCols := 0;
  DBGridEh.Columns.Clear;
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.FieldName     := 'TMC_NAME';
  lk_Col.Title.Caption := 'ТМЦ|Наименование';
  lk_Col.Width         := 320;
  lk_Col.Tag           := 666;
  lk_Col.Footer.ValueType := fvtStaticText;
  lk_Col.Footer.Alignment := taLeftJustify;
  lk_Col.Footer.Value     := 'ИТОГО';
  if TrTemp.InTransaction then TrTemp.Rollback;
  TrTemp.StartTransaction;
  QrTemp.SQL.Text := 'SELECT T.ID, T.NAME FROM TABL$R_PLACE T ';
  try
    QrTemp.ExecQuery;
  except
    DBGridEh.Visible := true;
    exit;
  end;
  lk_vars_s := '';
  lk_body_s := '';
  lk_total_s:= '';
  lk_vars_u := '';
  lk_body_u := '';
  while not QrTemp.Eof do
    begin
    lk_FldName := FldPrfx + FormatFloat('000000', QrTemp.FN('ID').AsInteger);
    lk_vars_s  := lk_vars_s + '  ,'+lk_FldName+' NUMERIC(15,3) '+#13#10;
    lk_body_s  := lk_body_s +'    SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT '+
      'Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '''+
      QrTemp.FN('ID').AsString+''') INTO :'+lk_FldName+'; '+#13#10;
    lk_vars_u  := lk_vars_u + '  ,Q_'+lk_FldName+' NUMERIC(15,3) = ?'+lk_FldName+' '+#13#10;
    lk_body_u  := lk_body_u +
      '  UPDATE TABL$P_TMC_QUANT QNT SET '+#13#10+
      '    QNT.QUANT = :Q_'+lk_FldName+' '+#13#10+
      '  WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '''+QrTemp.FN('ID').AsString+''' ); '+#13#10;
    lk_total_s := lk_total_s + ' + :'+lk_FldName;
    lk_Col               := DBGridEh.Columns.Add;
    lk_Col.FieldName     := lk_FldName;
    lk_Col.DisplayFormat := '# ### ##0';
    lk_Col.Title.Caption := 'Места хранения|'+QrTemp.FN('NAME').AsString;
    lk_Col.Width         := 56;
    lk_Col.Footer.ValueType     := fvtSum;
    lk_Col.Footer.FieldName     := lk_FldName;
    lk_Col.Footer.DisplayFormat := '# ### ##0';
    lk_Col.Footer.Alignment     := taRightJustify;
    QrTemp.Next;
    end;
  if TrTemp.InTransaction then TrTemp.Rollback;
  lk_FldName := FldPrfx + 'TOTAL';
  lk_vars_s  := lk_vars_s + '  ,'+lk_FldName+' NUMERIC(15,3) '+#13#10;
  lk_body_s  := '    '+lk_FldName+' = 0;'+#13#10+
    lk_body_s + '    '+lk_FldName+' = :'+lk_FldName+lk_total_s+';'+#13#10;
  lk_Col               := DBGridEh.Columns.Add;
  lk_Col.Color         := DBGridEh.FixedColor;
  lk_Col.DisplayFormat := '# ### ##0';
  lk_Col.FieldName     := lk_FldName;
  lk_Col.Title.Caption := 'ИТОГО';
  lk_Col.Width         := 62;
  lk_Col.Footer.ValueType     := fvtSum;
  lk_Col.Footer.FieldName     := lk_FldName;
  lk_Col.Footer.DisplayFormat := '# ### ##0';
  lk_Col.Footer.Alignment     := taRightJustify;
  pFIBDataSet.SQLs.SelectSQL.Text :=
    'EXECUTE BLOCK RETURNS( '+#13#10+
    '   TMC_ID   INTEGER '+#13#10+
    '  ,TMC_NAME VARCHAR(255)'+#13#10+
    lk_vars_s+
    ')AS '+#13#10+
    'BEGIN '+#13#10+
    '  FOR SELECT T.ID, T.NAME FROM TABL$R_TMC T ORDER BY T.NAME INTO :TMC_ID, :TMC_NAME DO '+#13#10+
    '    BEGIN '+#13#10+
    lk_body_s+
    '    SUSPEND;'+#13#10+
    '    END '+#13#10+
    'END ';
  pFIBDataSet.SQLs.RefreshSQL.Text :=
    'EXECUTE BLOCK ( '+#13#10+
    '  Q_TMC_ID INTEGER = ?TMC_ID '+#13#10+
    ')RETURNS( '+#13#10+
    '   TMC_ID   INTEGER '+#13#10+
    '  ,TMC_NAME VARCHAR(255)'+#13#10+
    lk_vars_s+
    ')AS '+#13#10+
    'BEGIN '+#13#10+
    '  TMC_ID = :Q_TMC_ID; '+#13#10+
    '  SELECT FIRST 1 T.NAME FROM TABL$R_TMC T WHERE(T.ID = :TMC_ID) INTO :TMC_NAME; '+#13#10+
    lk_body_s+
    '  SUSPEND;'+#13#10+
    'END ';
  pFIBDataSet.SQLs.UpdateSQL.Text :=
    'EXECUTE BLOCK ( '+#13#10+
    '  Q_TMC_ID INTEGER = ?TMC_ID '+#13#10+
    lk_vars_u+
    ')AS '+#13#10+
    'BEGIN '+#13#10+
    lk_body_u+
    'END ';
  if not trRead.InTransaction then
    trRead.StartTransaction;
  try
    pFIBDataSet.Open;
  except
  end;
  DBGridEh.FrozenCols  := 1;
  DBGridEh.Visible     := true;
end;
 
В результате выполнения этой процедуры, в свойство pFIBDataSet.SQLs будут помещены следующие скрипты:

--------------------------------------------------------------------------------
--- pFIBDataSet.SQLs.SelectSQL.Text
EXECUTE BLOCK RETURNS(
TMC_ID   INTEGER
,TMC_NAME VARCHAR(255)
  ,QNT000001 NUMERIC(15,3)
,QNT000002 NUMERIC(15,3)
,QNT000003 NUMERIC(15,3)
,QNT000004 NUMERIC(15,3)
,QNT000005 NUMERIC(15,3)
,QNTTOTAL NUMERIC(15,3)
)AS
BEGIN
FOR SELECT T.ID, T.NAME FROM TABL$R_TMC T ORDER BY T.NAME INTO :TMC_ID, :TMC_NAME DO
BEGIN
QNTTOTAL = 0;
    SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '1') INTO :QNT000001;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '2') INTO :QNT000002;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '3') INTO :QNT000003;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '4') INTO :QNT000004;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '5') INTO :QNT000005;
QNTTOTAL = :QNTTOTAL + :QNT000001 + :QNT000002 + :QNT000003 + :QNT000004 + :QNT000005;
    SUSPEND;
    END
END
--------------------------------------------------------------------------------
--- pFIBDataSet.SQLs.RefreshSQL.Text
EXECUTE BLOCK (
Q_TMC_ID   INTEGER = ?TMC_ID
)RETURNS(
TMC_ID    INTEGER
,TMC_NAME  VARCHAR(255)
  ,QNT000001 NUMERIC(15,3)
,QNT000002 NUMERIC(15,3)
,QNT000003 NUMERIC(15,3)
,QNT000004 NUMERIC(15,3)
,QNT000005 NUMERIC(15,3)
,QNTTOTAL  NUMERIC(15,3)
)AS
BEGIN
TMC_ID = :Q_TMC_ID;
SELECT FIRST 1 T.NAME FROM TABL$R_TMC T WHERE(T.ID = :TMC_ID) INTO :TMC_NAME;
QNTTOTAL = 0;
    SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '1') INTO :QNT000001;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '2') INTO :QNT000002;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '3') INTO :QNT000003;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '4') INTO :QNT000004;
SELECT FIRST 1 Q.QUANT FROM TABL$P_TMC_QUANT Q WHERE (Q.TMC_ID = :TMC_ID)AND(Q.PLACE_ID = '5') INTO :QNT000005;
QNTTOTAL = :QNTTOTAL + :QNT000001 + :QNT000002 + :QNT000003 + :QNT000004 + :QNT000005;
  SUSPEND;
END
--------------------------------------------------------------------------------
--- pFIBDataSet.SQLs.UpdateSQL.Text
EXECUTE BLOCK (
Q_TMC_ID    INTEGER       = ?TMC_ID
,Q_QNT000001 NUMERIC(15,3) = ?QNT000001
,Q_QNT000002 NUMERIC(15,3) = ?QNT000002
,Q_QNT000003 NUMERIC(15,3) = ?QNT000003
,Q_QNT000004 NUMERIC(15,3) = ?QNT000004
,Q_QNT000005 NUMERIC(15,3) = ?QNT000005
)AS
BEGIN
UPDATE TABL$P_TMC_QUANT QNT SET
QNT.QUANT = :Q_QNT000001
WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '1' );
UPDATE TABL$P_TMC_QUANT QNT SET
QNT.QUANT = :Q_QNT000002
WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '2' );
UPDATE TABL$P_TMC_QUANT QNT SET
QNT.QUANT = :Q_QNT000003
WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '3' );
UPDATE TABL$P_TMC_QUANT QNT SET
QNT.QUANT = :Q_QNT000004
WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '4' );
UPDATE TABL$P_TMC_QUANT QNT SET
QNT.QUANT = :Q_QNT000005
WHERE (QNT.TMC_ID = :Q_TMC_ID)AND(QNT.PLACE_ID = '5' );
END
На этапе прогона программы мы получим живой набор данных, который можно редактировать прямо в сетке. Как видно из примера, мы создали в рантайм только Update-скрипт, который позволяет производить модификацию данных. Таким же образом можно сделать Insert и Delete скрипты, которые будут осуществлять вставку и удаление записей, что мы предоставим сделать читателю самостоятельно. Область применения Cross-Tab достаточно широка и не ограничивается только "Остатками по складу". Не ограничивается эта область только очетами, иногда требуется делать макеты ввода данных для пользователей, что мы и рассмотрели в данной статье. Аналогичным образом можно сделать и "Табель выходов", который ведет начальник подразделения предприятия, отмечая выход на работу, больничные, отпускные и т.д.
5.Заключение
Как говорилось ранее, автор не претендует на роль истины в последней инстанции. Если Вам в чем-то помогла эта статья - автору будет приятно, что его труд не пропал даром. Если после прочтения даной статьи у Вас возникла мысль о том как можно сделать это оптимальнее, давайте это обсудим. И, думаю, эти знания пригодятся многим.