Oracle - триггеры для создания строки истории при обновлении - PullRequest
8 голосов
/ 10 февраля 2010

Во-первых, в настоящее время у нас есть желаемое поведение, но его нетривиально поддерживать, когда необходимы какие-либо изменения в базе данных. Я ищу что-нибудь более простое, более эффективное или более простое в обслуживании (все, что делает что-либо из этих 3, было бы очень кстати). Когда мы выполняем обновление, создается строка истории, которая является копией строки current , и значения текущей строки обновляются. В результате мы имеем запись истории того, как была строка до ее обновления.

Обоснование: мы должны соблюдать ряд федеральных правил и пошли по этому пути, чтобы иметь полную историю аудита всего, а также мы можем посмотреть на базу данных в любой момент времени и посмотреть, как все выглядело ( будущее требование). По тем же причинам я не могу изменить способ записи истории ... любое решение должно приводить к тем же данным, что и текущие триггеры.

Вот как выглядят текущие триггеры для таблицы Contact:
(для краткости удалены бесполезные поля, количество полей не имеет значения)

Перед обновлением (каждая строка):

DECLARE
     indexnb number;
BEGIN
  :new.date_modified := '31-DEC-9999';
  indexnb := STATE_PKG.newCONTACTRows.count + 1;
  :new.date_start := sysdate;
  :new.version := :old.version + 1;
  state_pkg.newCONTACTRows(indexnb).ID := :old.ID;
  state_pkg.newCONTACTRows(indexnb).PREFIX := :old.PREFIX;
  state_pkg.newCONTACTRows(indexnb).FIRST_NAME := :old.FIRST_NAME;
  state_pkg.newCONTACTRows(indexnb).MIDDLE_NAME := :old.MIDDLE_NAME;
  state_pkg.newCONTACTRows(indexnb).LAST_NAME := :old.LAST_NAME;
  --Audit columns after this
  state_pkg.newCONTACTRows(indexnb).OWNER := :old.OWNER;
  state_pkg.newCONTACTRows(indexnb).LAST_USER := :old.LAST_USER;
  state_pkg.newCONTACTRows(indexnb).DATE_CREATED := :old.DATE_CREATED;
  state_pkg.newCONTACTRows(indexnb).DATE_MODIFIED := sysdate;
  state_pkg.newCONTACTRows(indexnb).VERSION := :old.VERSION;
  state_pkg.newCONTACTRows(indexnb).ENTITY_ID := :old.id;
  state_pkg.newCONTACTRows(indexnb).RECORD_STATUS := :old.RECORD_STATUS;
  state_pkg.newCONTACTRows(indexnb).DATE_START := :old.DATE_START;
END;

Перед обновлением (один раз для всех строк):

BEGIN
  state_pkg.newCONTACTRows := state_pkg.eCONTACTRows;
END;

После обновления (один раз для всех строк):

DECLARE
BEGIN
  for i in 1 .. STATE_PKG.newCONTACTRows.COUNT loop
    INSERT INTO "CONTACT" (
      ID, 
      PREFIX, 
      FIRST_NAME, 
      MIDDLE_NAME, 
      LAST_NAME, 
      OWNER, 
      LAST_USER, 
      DATE_CREATED, 
      DATE_MODIFIED, 
      VERSION, 
      ENTITY_ID, 
      RECORD_STATUS, 
      DATE_START)
    VALUES (
      CONTACT_SEQ.NEXTVAL, 
      state_pkg.newCONTACTRows(i).PREFIX,
      state_pkg.newCONTACTRows(i).FIRST_NAME,
      state_pkg.newCONTACTRows(i).MIDDLE_NAME,
      state_pkg.newCONTACTRows(i).LAST_NAME,
      state_pkg.newCONTACTRows(i).OWNER,
      state_pkg.newCONTACTRows(i).LAST_USER,
      state_pkg.newCONTACTRows(i).DATE_CREATED,
      state_pkg.newCONTACTRows(i).DATE_MODIFIED,
      state_pkg.newCONTACTRows(i).VERSION,
      state_pkg.newCONTACTRows(i).ENTITY_ID,
      state_pkg.newCONTACTRows(i).RECORD_STATUS,
      state_pkg.newCONTACTRows(i).DATE_START
    );
  end loop;
END;

Пакет, определенный как (обрезанный, полная версия - просто копия этого для каждой таблицы):

PACKAGE STATE_PKG IS
  TYPE CONTACTArray IS TABLE OF CONTACT%ROWTYPE INDEX BY BINARY_INTEGER; 
  newCONTACTRows CONTACTArray; 
  eCONTACTRows CONTACTArray;
END;

Текущий результат

Вот пример полученной истории:

ID    First Last   Ver  Entity_ID  Date_Start              Date_Modified  
1196  John  Smith  5    0          12/11/2009 10:20:11 PM  12/31/9999 12:00:00 AM
1201  John  Smith  0    1196       12/11/2009 09:35:20 PM  12/11/2009 10:16:49 PM
1203  John  Smith  1    1196       12/11/2009 10:16:49 PM  12/11/2009 10:17:07 PM
1205  John  Smith  2    1196       12/11/2009 10:17:07 PM  12/11/2009 10:17:19 PM
1207  John  Smith  3    1196       12/11/2009 10:17:19 PM  12/11/2009 10:20:00 PM
1209  John  Smith  4    1196       12/11/2009 10:20:00 PM  12/11/2009 10:20:11 PM

Каждая запись истории имеет Entity_ID, который является идентификатором текущей строки, Date_Start для новой записи соответствует Date_Modified последней строки истории. Это позволяет нам делать запросы вроде Where Entity_ID = :id Or ID = :id And :myDate < Date_Modified And :myDate >= Date_Start. Историю можно получить по Entity_ID = :current_id.

Есть ли лучший подход, который, как мы надеемся, более удобен в обслуживании / гибкий для этого? Концепция проста: при обновлении строки скопируйте ее в ту же таблицу через вставку со старыми значениями, а затем обновите текущая строка ... но на самом деле, делая это, мне еще предстоит найти более простой способ. Я надеюсь, что кто-то намного хитрее / мудрее в Oracle имеет лучший подход к этому. Скорость не имеет большого значения, мы на 99% читаем 1% записей, как и большинство веб-приложений, и все массовые операции являются вставками, а не обновлениями, которые не будут создавать историю.

Если у кого-нибудь есть идеи по упрощению обслуживания, я был бы очень признателен, спасибо!

Ответы [ 7 ]

4 голосов
/ 11 февраля 2010

В случае, если у кого-то есть такой же узкоспециализированный случай, как у нас (доступ Linq делает историю отдельных таблиц намного чище / проще, это то, что я в итоге сделал, чтобы упростить то, что у нас есть, приветствую любые улучшения ... это просто скрипт, который будет запускаться всякий раз, когда база данных изменяется, генерируя триггеры аудита, главное изменение - PRAGMA AUTONOMOUS_TRANSACTION;, помещая историю, генерирующую автономную транзакцию, и не заботясь о мутации (что не имеет значения для нашего аудита):

Declare
  cur_trig varchar(4000);
  has_ver number;
Begin
    For seq in (Select table_name, sequence_name 
              From user_tables ut, user_sequences us
              Where sequence_name = replace(table_name, '_','') || '_SEQ'
                And table_name Not Like '%$%'
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'ID' And ut.table_name = utc.table_name)
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'DATE_START' And ut.table_name = utc.table_name)
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'DATE_MODIFIED' And ut.table_name = utc.table_name))
    Loop
     --ID Insert Triggers (Autonumber for oracle!)
     cur_trig := 'CREATE OR REPLACE TRIGGER ' || seq.table_name || 'CR' || chr(10)
              || 'BEFORE INSERT ON ' || seq.table_name || chr(10)
              || 'FOR EACH ROW' || chr(10)
              || 'BEGIN' || chr(10)
              || '  SELECT ' || seq.sequence_name || '.NEXTVAL INTO :new.ID FROM DUAL;' || chr(10)
              || '  IF(:NEW.ENTITY_ID = 0) THEN' || chr(10)
              || '    SELECT sysdate, sysdate, ''31-DEC-9999'' INTO :NEW.DATE_CREATED, :NEW.DATE_START, :NEW.DATE_MODIFIED FROM DUAL;' || chr(10)
              || '  END IF;' || chr(10)
              || 'END;' || chr(10);

     Execute Immediate cur_trig;

     --History on update Triggers
     cur_trig := 'CREATE OR REPLACE TRIGGER ' || seq.table_name || '_HIST' || chr(10)
              || '  BEFORE UPDATE ON ' || seq.table_name || ' FOR EACH ROW' || chr(10)
              || 'DECLARE' || chr(10)
              || '  PRAGMA AUTONOMOUS_TRANSACTION;' || chr(10)
              || 'BEGIN' || chr(10)
              || '  INSERT INTO ' || seq.table_name || ' (' || chr(10)
              || '   DATE_MODIFIED ' || chr(10)
              || '   ,ENTITY_ID ' || chr(10);

       For col in (Select column_name
                 From user_tab_columns ut
                 Where table_name = seq.table_name
                   And column_name NOT In ('ID','DATE_MODIFIED','ENTITY_ID')
                 Order By column_name)
     Loop
       cur_trig := cur_trig || '   ,' || col.column_name || chr(10);
     End Loop;

     cur_trig := cur_trig || ') VALUES ( --ID is Automatic via another trigger' || chr(10)
                          || '   SYSDATE --DateModified Set' || chr(10)
                          || '   ,:old.ID --EntityID Set' || chr(10);

     has_ver := 0;
       For col in (Select column_name
                 From user_tab_columns ut
                 Where table_name = seq.table_name
                   And column_name NOT In ('ID','DATE_MODIFIED','ENTITY_ID')
                 Order By column_name)
     Loop
       cur_trig := cur_trig || '   ,:old.' || col.column_name || chr(10);
       If Upper(col.column_name) = 'VERSION' Then 
         has_ver := 1; 
       End If;
     End Loop;

     cur_trig := cur_trig || ');' || chr(10)
                          || ':new.DATE_MODIFIED := ''31-DEC-9999'';' || chr(10)
                          || ':new.DATE_START := SYSDATE;' || chr(10);
     If has_ver = 1 Then
       cur_trig := cur_trig || ':new.version := :old.version + 1;' || chr(10);
     End If;
     cur_trig := cur_trig || 'COMMIT;' || chr(10)
                          || 'END;' || chr(10);

     Execute Immediate cur_trig;
    End Loop;
End;
/

Если вы можете улучшиться, не стесняйтесь ... Я написал только несколько сценариев PL / SQL, необходимость не возникает часто ... вероятно, там многое осталось желать.

Ответьте кредит APC за то, что заставили меня взглянуть на это немного сложнее. Я не рекомендую этот макет истории, если это не остальная часть вашей модели / приложения / стека очень хорошо . Для этого приложения мы постоянно показываем смесь истории и современности, а фильтрация гораздо проще, чем комбинация, когда речь идет о доступе в стиле Linq-to-SQL. Спасибо за все ответы, ребята, за все хорошие предложения ... и когда у меня будет больше времени и я не буду ограничен графиком релизов, я вернусь к этому вопросу, чтобы увидеть, можно ли его улучшить еще.

4 голосов
/ 10 февраля 2010

Хорошо, это переписать. Что я пропустил, когда я впервые ответил, что приложение хранит свою историю в главной таблице. Теперь я понимаю, почему @NickCraver извиняется за код.

Ну, первое, что нужно сделать, это выследить преступников этого проекта и убедиться, что они никогда не сделают это снова. Такое хранение истории не масштабируется, усложняет обычные (неисторические) запросы и нарушает целостность отношений. Очевидно, что есть сценарии, когда ничего из этого не имеет значения, и, возможно, ваш сайт является одним из них, но в целом это очень плохая реализация.

Лучший способ сделать это - Общий вызов Oracle 11g . Это элегантное решение с полностью незаметной и эффективной реализацией и - по стандартам других платных дополнительных услуг Oracle - по вполне разумной цене.

Но если Total Recall не может быть и речи, и вы действительно должны это сделать, не разрешать обновления . Изменение существующей записи CONTACT должно быть вставкой. Для этого вам может понадобиться создать представление с помощью триггера INSTEAD OF. Это по-прежнему отвратительно, но не совсем так, как сейчас.


Начиная с Oracle 11.2.0.4 Total Recall был переименован в Flashback Archive и включен в состав лицензии Enterprise (хотя и не содержит сжатых журнальных таблиц, если только мы не приобретем опцию Advanced Compress).

Эта щедрость от Oracle должна сделать FDA обычным способом хранения истории: он эффективен, он эффективен, это встроенный Oracle со стандартным синтаксисом для поддержки исторических запросов. Увы, я ожидаю увидеть еще полуготовые реализации с триггерами с spatchcocked, сломанными первичными ключами и ужасной производительностью в течение многих лет. Потому что журналирование кажется одним из тех отвлекающих факторов, которые радуют разработчиков, несмотря на тот факт, что это низкоуровневая сантехника, которая в значительной степени не имеет отношения к 99,99% всех бизнес-операций.

4 голосов
/ 10 февраля 2010

К сожалению, нет способа избежать ссылки на все имена столбцов (: OLD.this,: OLD.that и т. Д.) В триггерах. Однако вы можете написать программу для генерации кода триггера из определения таблицы (в USER_TAB_COLS). Затем при каждом изменении таблицы вы можете сгенерировать и скомпилировать новую копию триггеров.

См. эту ветку AskTom , как это сделать.

2 голосов
/ 11 февраля 2010

Я понимаю, что ваши требования к конкретному приложению должны иметь историю и текущие значения в одной и той же таблице, но, возможно, это можно было бы сделать, перейдя по более обычному пути, заключающемуся в отдельной таблице аудита, но создав ее как псевдо-материализованное представление представить комбинированный вид для приложения.

Для меня это имеет преимущество, заключающееся в простом «текущем» представлении и отдельном, но полностью автоматизированном представлении «аудита» (которое в данном случае также имеет текущее представление).

Что-то вроде:

create sequence seq_contact start with 1000 increment by 1 nocache nocycle;

create table contact (
    contact_id integer,
    first_name varchar2(120 char),
    last_name varchar2(120 char),
    last_update_date date
    );

alter table contact add constraint pk_contact primary key (contact_id);

create table a$contact (
    version_id integer,
    contact_id integer,
    first_name varchar2(120 char),
    last_name varchar2(120 char),
    last_update_date date
    );

alter table a$contact add constraint pk_a$contact primary key
        (contact_id, version_id);

create or replace trigger trg_contact
before insert or delete or update on contact 
for each row
declare

    v_row contact%rowtype;
    v_audit a$contact%rowtype;

begin

    select seq_contact.nextval into v_audit.version_id from dual;

    if not deleting then

        :new.last_update_date := sysdate;

    end if;

    if inserting or updating then

        v_audit.contact_id := :new.contact_id;
        v_audit.first_name := :new.first_name;
        v_audit.last_name := :new.last_name;
        v_audit.last_update_date := :new.last_update_date;

    elsif deleting then

        v_audit.contact_id := :old.contact_id;
        v_audit.first_name := :old.first_name;
        v_audit.last_name := :old.last_name;
        v_audit.last_update_date := sysdate;

    end if;

    insert into a$contact values v_audit;

end trg_contact;
/

insert into contact (contact_id, first_name, last_name) values
    (1,'Nick','Pierpoint');

insert into contact (contact_id, first_name, last_name) values
    (2, 'John', 'Coltrane');

insert into contact (contact_id, first_name, last_name) values
    (3, 'Sonny', 'Rollins');

insert into contact (contact_id, first_name, last_name) values
    (4, 'Kenny', 'Wheeler');

update contact set last_name = 'Cage' where contact_id = 1;

delete from contact where contact_id = 1;

update contact set first_name = 'Zowie' where contact_id in  (2,3);

select * from a$contact order by contact_id, version_id;

VERSION_ID  CONTACT_ID  FIRST_NAME  LAST_NAME  LAST_UPDATE_DATE
1000        1           Nick        Pierpoint  11/02/2010 14:53:49
1004        1           Nick        Cage       11/02/2010 14:54:00
1005        1           Nick        Cage       11/02/2010 14:54:06
1001        2           John        Coltrane   11/02/2010 14:53:50
1006        2           Zowie       Coltrane   11/02/2010 14:54:42
1002        3           Sonny       Rollins    11/02/2010 14:53:51
1007        3           Zowie       Rollins    11/02/2010 14:54:42
1003        4           Kenny       Wheeler    11/02/2010 14:53:53
1 голос
/ 10 февраля 2010

В зависимости от сложности вашей базы данных (количество таблиц, размер, глубина отношений PK / FK, другая логика в триггерах), вы можете захотеть взглянуть на Oracle Workspace Management . Вы делаете вызов API, чтобы поместить таблицу под управление рабочей областью, в результате чего Oracle заменяет таблицу обновляемым представлением и другими соответствующими объектами, которые хранят историю всех версий строк.

Я использовал это, и хотя есть недостатки, одно из преимуществ аудита заключается в том, что все объекты кода генерируются Oracle, и обычно предполагается, что их правильность.

1 голос
/ 10 февраля 2010

Если вы хотите разработать общее решение, вы можете взглянуть на пакет DBMS_SQL. С его помощью вы можете разработать пакет / процедуру, которая принимает имя таблицы в качестве входных данных и строит обновления на основе этого, изучая структуру таблицы в словаре и создавая обновления на лету. Это будет нетривиальная предварительная разработка, но в будущем будет намного меньше обслуживания, поскольку, если структура таблицы изменится, код это почувствует и адаптирует. Этот метод будет работать для любой таблицы, с которой вы хотите его использовать.

0 голосов
/ 11 февраля 2010

Единственный раз, когда я могу рекомендовать хранить исторические записи в той же таблице, что и «текущие» записи, это когда FK-ссылки на записи должны или могут нуждаться в ссылках на них. Например, в одном приложении, которое я видел, было несколько ссылок на FK, которые связывали бы запись с «моментом времени», то есть, если запись была обновлена, FK все равно ссылался бы на историческую запись - это было важная часть дизайна и разделение записей истории во вторую таблицу сделало бы ее более громоздкой.

Кроме того, я бы предпочел, чтобы бизнес-требование для отслеживания всех изменений решалось с использованием отдельной таблицы «истории» для каждой таблицы. Конечно, это означает больше DDL, но это значительно упрощает код приложения, и вы также выиграете от повышения производительности и масштабируемости.

...