В своей первой статье по InterBase хочется остановиться на достаточно нетривиальном вопросе. Тем, кто переходит с локальных СУБД типа Paradox, DBISAM и т. д. возможно, как и мне не хватает автоматического и немедленного обновление данных в таблицах (на стороне клиента) при изменениях, производимыми одновременно несколькими пользователями.
Решение, которое приходит сразу кажется простым – реализовать события в триггерах, при получении которых набор данных обновляется. Также возможно запоминание курсора и возвращения его на место. Но… не гибко – если есть Calculated, Lookup поля, не самый маленький объем данных в таблице – не особо быстро выходит открытие при частых изменениях. Другое решение, предложенное мной сложнее, но, на мой взгляд, гораздо эффективнее. Суть в том, что при получении события обновлять набор данных у других клиентов посредством Delete, Edit, Append, но не генерируя SQL-запросов при этом.
Инструментарий: Firebird 1.0 (см.
http://www.ibase.ru – там найдете), FIBPlus 4.4.2 (
http://www.devrace.ru). Библиотека FIBPlus последнее время мне все больше и больше нравится и даже не по скоростным характеристикам, что приводятся у них на сайте, а по удобству и возможностям. IBX уступает намного, особенно TpFIBDataSet и его 2 транзакции (UpdateTransaction) и возможность восстановления соединения с базой, куча всяких приятных мелочей. С помощью нее такая картина и получается. Итак, приступим по шагам. Повторю “задание”: при обновлении набора данных одним клиентом, эти обновления должны отражаться сразу и у других клиентов.
Возьмем наипростейшую таблицу с одним полем-ключем и одним информативным полем. Условимся, что при изменении строки, ключ мы менять не будем (вообще то я считаю это хорошим тоном). Итак:
CREATE TABLE T1 (
INTF INTEGER NOT NULL, //это будет ключ
STRF CHAR(20), // а это одно текстовое поле
USERID INTEGER, // идентификатор пользователя
PRIMARY KEY (INTF));
Остановимся подробно на идентификаторе пользователя – целое число, он нам в дальнейшем будет говорить, кто из пользователей изменил или добавил запись (но не удалил!). Обычно я его прописываю каждому пользователю в INI-файл или в отдельную таблицу. Заполняется оно на событии BeforePost или OnNewRecord. В тестовом проекте сделайте TEdit с цифрой, чтобы запустить программу 2 раза, а в TEdit’ах установить 1 и 2 соответственно. Также сделаем автоматически изменяемую таблицу для регистрации действий, вот она:
CREATE TABLE AU_T1 (
INTF INTEGER NOT NULL, // все поля идентичны, но нет индексов, даже первичного
STRF CHAR(20),
USERID INTEGER,
AU_NUM INTEGER, // “возраст” изменения. Станет ясно ниже.
AU_ACTION CHAR(1)); // Действие: “I”, “U”, “D” = Insert, Update, Delete
Теперь начнем длинный разговор о триггерах. Основная идея – таблица AU_T1 обновляется (растет) при работе с таблицей T1. Ключевым тут является событие t1, которое обрабатывают все клиенты. Также используется генератор:
CREATE GENERATOR GEN_T1.
SET TERM ^ ;
/* Triggers definition */
/* Trigger: "trig_T1_ad" */
CREATE TRIGGER "trig_T1_ad" FOR T1
ACTIVE AFTER DELETE POSITION 0
as
begin
post_event 't1';
end
^
/* Trigger: "trig_t1_ai" */
CREATE TRIGGER "trig_t1_ai" FOR T1
ACTIVE AFTER INSERT POSITION 0
as
begin
insert into au_t1 (intf,strf,userid,au_num,au_action) values
new.intf,new.strf,new.userid,GEN_ID(gen_t1, 1),'I');
post_event 't1';
end
^
/* Trigger: "trig_t1_au" */
CREATE TRIGGER "trig_t1_au" FOR T1
ACTIVE AFTER UPDATE POSITION 0
as
begin
insert into au_t1 (intf,strf,userid,au_num,au_action) values
(new.intf,new.strf,new.userid,GEN_ID(gen_t1, 1),'U');
post_event 't1';
end
^
/* Trigger: "trig_t1_bd" */
CREATE TRIGGER "trig_t1_bd" FOR T1
ACTIVE BEFORE DELETE POSITION 0
as
begin
insert into au_t1 (intf,strf,userid,au_num,au_action) values
(old.intf,old.strf,old.userid,GEN_ID(gen_t1, 1),'D');
end
^
SET TERM ; ^
Также есть триггер и у таблицы AU_T1:
CREATE TRIGGER "trig_AU_T1_ai" FOR AU_T1
ACTIVE AFTER INSERT POSITION 0
as
declare variable max_num integer;
begin
select max(au_num) from au_t1 into :max_num;
delete from au_t1 where au_num < :max_num - 50;
end
Обратите внимание на число 50 – это максимальное число записей в этой таблице. Данный триггер не дает ей разрастаться. 50 записей мне хватает за глаза. Суть в том, что если клиент редактирует набор данных, он обновлений не увидит, он увидит их сразу после того, как сделает Post. В принципе для этого хватит и 5 записей запаса, но вдруг, перейдя в режим редактирования, клиент пойдет попить чай. А хотя все это не смертельно: кнопку “освежить набор данных” все-таки лучше предусмотреть. Все, переходим к разработке клиентской части.
Создаем проект, ставим на форму: базу данных (fibDB), 2 транзакции (fibtrT1, fibtrT1W), 2 датасета – TpFIBDataSet (fibdsT1, fibdsAU_T1), DataSource, DBGrid, pFIBEventer (не забудьте скомпилировать пакет библиотеки FIBPlus с этим компонентом – по умолчанию он отключен – в *.inc файле). Связываем все так: fibdsT1 – DataSource – DBGrid.
Транзакции. У обоих наборов данных свойство Transaction:=fibtrT1, у fibdsT1 свойство UpdateTransaction:= fibtrT1W. Вообще-то лучше было бы посадить fibdsAU_T1 на собственную транзакцию, но, … и так работает. Транзакции я сделал read-committed. У fibtrT1W установим TimeoutAction:=TACommit. Итак, все изменения в таблице будут проходить в короткой транзакции, она будет автоматически стартовать и завершаться (Commit). Свойства для этого: fibdsT1.AutoCommit:=true; fibdsT1.Options.poStartTransaction:=true; poAllowChangeSQL:=true;
Теперь сгенерируем SQL’ы для таблицы fibdsT1, а впрочем, они стандартные:
Delete:
DELETE FROM T1
WHERE
INTF = ?OLD_INTF
Insert:
INSERT INTO T1 (INTF, STRF, USERID)
VALUES (?INTF, ?STRF, ?USERID)
Refresh:
SELECT T1.INTF, T1.STRF, T1.USERID
FROM T1
WHERE (T1.INTF = ?OLD_INTF)
Select:
SELECT T1.INTF, T1.STRF, T1.USERID
FROM T1
Update:
UPDATE T1 SET
STRF = ?STRF,
USERID = ?USERID
WHERE
INTF = ?OLD_INTF
Для fibdsAU_T1 ничего больше делать не будем. Что еще? Ах да, установим у pFIBEventer'а в Events одну строку “t1” – мы будем получать это событие.
Глобальные переменные:
var
t1_max_num: integer;
t1_event_locked: boolean;
t1_non_upd_rec: integer;
События:
События на кнопку “Открыть таблицу” (по умолчанию она закрыта):
fibdsAU_T1.Active := false;
fibdsT1.Active := false;
// Выбираем максимальный возраст записей.
// Вся суть возраста – это то что каждая новая запись помечается новым числом,
// большим на 1 предыдущего, а на каждом клиенте хранится номер последнего
// обновленного. Таким образом мы не выбираем дважды одни и те же записи
fibdsAU_T1.SelectSQL.Text := 'SELECT MAX(AU_NUM) FROM AU_T1';
fibdsAU_T1.Open;
t1_max_num := 0;
if not fibdsAU_T1.IsEmpty then t1_max_num :=
ibdsAU_T1.Fields[0].AsInteger;
fibdsT1.Open;
procedure TForm1.fibdsT1BeforePost(DataSet: TDataSet);
begin
// Если мы не вносим “чужие” записи, то устанавливаем владельца.
if Not t1_event_locked then
fibdsT1.FN('userid').asinteger:=strtoint(Edit1.text);
end;
procedure TForm1.pFIBEventer1EventAlert(Sender: TObject;
EventName: string; EventCount: Integer; var CancelAlerts: Boolean);
var updrec: integer;
sS, sU, sD: string;
iKey: integer;
begin
// t1_event_locked введен мною для того, чтобы предотвратить
// повторный вызов этого события, если находимся в нем.
// Может и излишняя предосторожность, но пусть,
// к тому же пригодилось в событии выше
if t1_event_locked then exit;
if EventName = 't1' then
begin
t1_event_locked := true;
fibdsAU_T1.Active := false;
// выбираем записи введенные или измененные не этим пользователем,
// это не относится к удаленным записям – они выбираются все.
fibdsAU_T1.SelectSQL.Text := 'SELECT * FROM AU_T1 where au_num>' + IntToStr(t1_max_num) +
' AND (USERID<>' + Edit1.Text+' or AU_ACTION=''D'') order by au_num';
fibdsAU_T1.Open;
fibdsAU_T1.Last; // Это для определения реального числа записей в RecordCount
fibdsAU_T1.First;
updrec := fibdsAU_T1.RecordCount;
//если не все записи будут обновлены
t1_non_upd_rec := updrec;
// Если редактируем, то уходим – сделаем это в другой раз,
// можно здесь пользователя предупредить об обновлениях
if fibdsT1.State <> dsBrowse then
begin
t1_event_locked := False;
fibdsT1.EnableControls;
exit;
end;
try
// Запоминаем значение ключа, чтобы потом поставить
// пользователя на нужную запись
iKey := fibdsT1.FN('INTF').AsInteger;
fibdsT1.DisableControls;
// Гуляя по исходникам библиотеки FIBPlus, я обнаружил, что если
// Поставить “No Action”, то SQL-операторы на сервер не передаются.
// Основная проблема была в том, что если оставить их пустыми, то
// методы Append, Edit будут недоступны. Однако при Delete этот фокус
// не пройдет, см. ниже.
sS := fibdsT1.InsertSQL.Text; fibdsT1.InsertSQL.Text:='No Action';
sU := fibdsT1.UpdateSQL.Text; fibdsT1.UpdateSQL.Text:='No Action';
sD := fibdsT1.DeleteSQL.Text; fibdsT1.DeleteSQL.Text:='No Action';
while not fibdsAU_T1.Eof do
begin
//Добавляем
if fibdsAU_T1.FN('AU_ACTION').AsString = 'I' then
begin
fibdsT1.Append; fibdsT1.FN('INTF').AsInteger := fibdsAU_T1.FN('INTF').AsInteger;
fibdsT1.FN('STRF').AsString := fibdsAU_T1.FN('STRF').AsString;
fibdsT1.FN('USERID').AsInteger := fibdsAU_T1.FN('USERID').AsInteger;
try
fibdsT1.Post;
dec(t1_non_upd_rec);
except
fibdsT1.Cancel;
end;
end;
// При удалении мы не можем четко узнать число записей,
// нужных для обновления – к ним примешиваются записи, удаленные и
// этим клиентом, но они, в таком случае, не будут найдены по Locate
if fibdsAU_T1.FN('AU_ACTION').AsString = 'D' then
begin
dec(t1_non_upd_rec);
dec(updrec);
end;
if fibdsT1.Locate('INTF', fibdsAU_T1.FN('INTF').AsInteger, []) then
begin
//удаляем, запись была не наша.
try
if fibdsAU_T1.FN('AU_ACTION').AsString = 'D' then
begin
fibdsT1.Delete;
inc(updrec);
end;
except end;
//Обновляем
if fibdsAU_T1.FN('AU_ACTION').AsString = 'U' then
begin
fibdsT1.Edit;
fibdsT1.FN('STRF').AsString := fibdsAU_T1.FN('STRF').AsString;
fibdsT1.FN('USERID').AsInteger := fibdsAU_T1.FN('USERID').AsInteger;
try
fibdsT1.Post;
dec(t1_non_upd_rec);
except
fibdsT1.Cancel;
end;
end;
end;
// Обновляем “возраст”, обработанные записи выбраны больше не будут
t1_max_num:=fibdsAU_T1.FN('AU_NUM').AsInteger;
fibdsAU_T1.Next;
end;
finally
// восстанавливаем все обратно
fibdsT1.InsertSQL.Text := sS;
fibdsT1.UpdateSQL.Text := sU;
fibdsT1.DeleteSQL.Text := sD;
if fibdsT1.State=dsBrowse then
fibdsT1.Locate('INTF',iKey,[]);
fibdsT1.EnableControls;
Label2.Caption:='';
if t1_non_upd_rec<>0 then
Label2.Caption:= 'Невозможно обновить '+ IntToStr(t1_non_upd_rec)+' записей'
else
Label2.Caption:='Другими пользователями обновлено '+ IntToStr(updrec)+' записей';
t1_event_locked := False;
end;
end;
end;
Да, не упомянул, важно: насчет DeleteSQL: там конструкция No Action по умолчанию не проходит, придется подпатчить библиотеку FIBPlus, а так не хотелось. Еще раз, версия 4.4.2, модуль pFIBDataSet:
procedure TpFIBDataSet.InternalDeleteRecord(Qry: TFIBQuery; Buff: Pointer);//override;
begin
AutoStartUpdateTransaction;
ExecUpdateObjects(ukDelete,Buff,oeBeforeDefault);
//plotn
//if Qry.SQL.Text<>'' then begin
if (Qry.SQL.Text<>'') and (Qry.SQL[0]<>'No Action') then
begin
//\plotn
SetQueryParams(Qry, Buff);
Qry.ExecQuery;
end;
ExecUpdateObjects(ukDelete,Buff,oeAfterDefault);
with PRecordData(Buff)^ do
begin
rdUpdateStatus := usDeleted;
rdCachedUpdateStatus := cusUnmodified;
end;
WriteRecordCache(PRecordData(Buff)^.rdRecordNumber, Buff);
if not FCachedUpdates then
AutoCommitUpdateTransaction;
FHasUncommitedChanges:=Transaction.State=tsActive;
end;
И кстати, патч этот не самый лучший – легко увидеть работа с UpdateObject’ами будет идти и при No Action (они будут вызываться для “чужих” записей), просто я их пока не использую и в данном примере они были не нужны, можно сделать и красивее, но нет времени на дополнительное тестирование.
И все. Конечно, достаточно сыро, но работает прилично. Кому надо рабочий пример, пишите, вышлю:
plotn@euro.ru. Все комментарии, дополнения также обязательно пишите туда. Может кто и сподобится написать соответствующий компонент для этого (сочетающий в себе и датасет, и eventer, и автоматизизирующий создание триггеров, и таблицы AU_…).