How to Track Who Changed What in Oracle APEX: A Change History for Any Table

One history table, a small package that creates the trigger for any table, and a Classic Report region that shows who changed what.

Sooner or later, someone asks: "Who changed the credit limit of this customer, and what was it before?" If the application does not record changes, nobody can answer.

This article adds a change history that works for any table in your schema. One table stores every change, one column per row: who changed it, when, the old value, and the new value. A small package creates the history trigger for a table with one call, so you do not write a trigger by hand for each table. Finally, a Classic Report region shows the history of the current record on the APEX form.

Customer form for Acme Corporation with a Change History region listing changes by EMILY and JOHN
The result: the form shows who changed what, and when

Step 1: Create the History Table and Package

Run this script in your schema, in SQL*Plus, SQLcl, SQL Developer, or SQL Workshop:

create table change_history (
  history_id   number generated always as identity primary key,
  table_name   varchar2(128)  not null,
  pk_value     varchar2(200)  not null,
  action       varchar2(6)    not null,
  column_name  varchar2(128)  not null,
  old_value    varchar2(4000),
  new_value    varchar2(4000),
  changed_by   varchar2(255)  not null,
  changed_on   timestamp      default systimestamp not null
);

create index change_history_row_ix on change_history (table_name, pk_value);

create or replace package change_history_api authid definer as
  -- creates (or replaces) the history trigger of a table
  procedure enable_history (p_table_name in varchar2);
  -- drops the history trigger of a table
  procedure disable_history (p_table_name in varchar2);
  -- called by the history triggers: logs one column if its value changed
  procedure log_change (
    p_table_name in varchar2, p_pk_value in varchar2, p_action in varchar2,
    p_column_name in varchar2, p_old_value in varchar2, p_new_value in varchar2);
end change_history_api;
/

create or replace package body change_history_api as

  procedure log_change (
    p_table_name in varchar2, p_pk_value in varchar2, p_action in varchar2,
    p_column_name in varchar2, p_old_value in varchar2, p_new_value in varchar2) is
  begin
    if (p_old_value is null and p_new_value is null) or p_old_value = p_new_value then
      return;                                        -- this column did not change
    end if;
    insert into change_history
      (table_name, pk_value, action, column_name, old_value, new_value, changed_by)
    values
      (p_table_name, p_pk_value, p_action, p_column_name, p_old_value, p_new_value,
       -- the APEX user, or the database user outside APEX
       coalesce(sys_context('APEX$SESSION', 'APP_USER'), sys_context('USERENV', 'SESSION_USER')));
  end log_change;

  procedure enable_history (p_table_name in varchar2) is
    l_table  varchar2(128) := upper(p_table_name);
    l_pk     varchar2(128);
    l_sql    varchar2(32767);

    -- a column value as text: dates and numbers in a fixed format
    function as_text (p_ref in varchar2, p_type in varchar2) return varchar2 is
    begin
      return case
        when p_type = 'DATE'             then 'to_char(' || p_ref || ', ''YYYY-MM-DD HH24:MI:SS'')'
        when p_type like 'TIMESTAMP%'    then 'to_char(' || p_ref || ', ''YYYY-MM-DD HH24:MI:SS'')'
        when p_type in ('NUMBER', 'FLOAT') then 'to_char(' || p_ref || ')'
        else 'substr(' || p_ref || ', 1, 4000)'
      end;
    end as_text;
  begin
    -- the table must have a single-column primary key
    select cc.column_name into l_pk
    from user_constraints c
    join user_cons_columns cc on cc.constraint_name = c.constraint_name
    where c.table_name = l_table and c.constraint_type = 'P';

    l_sql := 'create or replace trigger ' || l_table || '_HIST' || chr(10)
      || 'after insert or update or delete on ' || l_table || chr(10)
      || 'for each row' || chr(10)
      || 'declare' || chr(10)
      || '  l_action varchar2(6) := case when inserting then ''INSERT'''
      || ' when updating then ''UPDATE'' else ''DELETE'' end;' || chr(10)
      || '  l_pk varchar2(200) := coalesce(to_char(:new.' || l_pk || '), to_char(:old.' || l_pk || '));' || chr(10)
      || 'begin' || chr(10);

    for c in (select column_name, data_type
              from user_tab_columns
              where table_name = l_table
              and column_name <> l_pk                -- the key is in PK_VALUE already
              and (data_type in ('VARCHAR2', 'NVARCHAR2', 'CHAR', 'NCHAR', 'NUMBER', 'FLOAT', 'DATE')
                   or data_type like 'TIMESTAMP%')
              order by column_id) loop
      l_sql := l_sql || '  change_history_api.log_change(''' || l_table || ''', l_pk, l_action, '''
        || c.column_name || ''', ' || as_text(':old.' || c.column_name, c.data_type)
        || ', ' || as_text(':new.' || c.column_name, c.data_type) || ');' || chr(10);
    end loop;

    execute immediate l_sql || 'end;';
  end enable_history;

  procedure disable_history (p_table_name in varchar2) is
  begin
    execute immediate 'drop trigger ' || upper(p_table_name) || '_HIST';
  end disable_history;

end change_history_api;
/

Here is what it creates:

  • CHANGE_HISTORY stores one row per changed column: the table name, the primary key of the row (PK_VALUE), the action (INSERT, UPDATE, or DELETE), the column name, the old and new values as text, the user, and the time.
  • LOG_CHANGE writes a history row, but only when the value really changed, so an update that saves a whole form records only the fields the user changed. The user is the APEX user from sys_context('APEX$SESSION', 'APP_USER'). Outside APEX, for example in SQL*Plus or a scheduler job, it is the database user.
  • ENABLE_HISTORY reads the columns of a table from USER_TAB_COLUMNS and creates a trigger named TABLE_HIST that calls LOG_CHANGE for each column. Dates and numbers are stored in a fixed text format. Large columns such as CLOB and BLOB are skipped.
  • DISABLE_HISTORY drops that trigger again.

Step 2: Turn On History for a Table

The demo uses a CUSTOMERS table with the columns CUSTOMER_ID (the primary key), CUSTOMER_NAME, EMAIL, PHONE, CREDIT_LIMIT, and STATUS. To record its changes, run:

begin
  change_history_api.enable_history('CUSTOMERS');
end;
/

That is all. The package has generated this trigger:

trigger CUSTOMERS_HIST
after insert or update or delete on CUSTOMERS
for each row
declare
  l_action varchar2(6) := case when inserting then 'INSERT' when updating then 'UPDATE' else 'DELETE' end;
  l_pk varchar2(200) := coalesce(to_char(:new.CUSTOMER_ID), to_char(:old.CUSTOMER_ID));
begin
  change_history_api.log_change('CUSTOMERS', l_pk, l_action, 'CUSTOMER_NAME', substr(:old.CUSTOMER_NAME, 1, 4000), substr(:new.CUSTOMER_NAME, 1, 4000));
  change_history_api.log_change('CUSTOMERS', l_pk, l_action, 'EMAIL', substr(:old.EMAIL, 1, 4000), substr(:new.EMAIL, 1, 4000));
  change_history_api.log_change('CUSTOMERS', l_pk, l_action, 'PHONE', substr(:old.PHONE, 1, 4000), substr(:new.PHONE, 1, 4000));
  change_history_api.log_change('CUSTOMERS', l_pk, l_action, 'CREDIT_LIMIT', to_char(:old.CREDIT_LIMIT), to_char(:new.CREDIT_LIMIT));
  change_history_api.log_change('CUSTOMERS', l_pk, l_action, 'STATUS', substr(:old.STATUS, 1, 4000), substr(:new.STATUS, 1, 4000));
end;

To record another table, call enable_history with its name. If you add columns to a table later, call enable_history again, and the trigger is created again with the new columns.

Step 3: Test It

Change a customer in SQL and read its history:

update customers
set credit_limit = 30000, email = 'ap@globex.example.com'
where customer_id = 2;
commit;

select action, column_name, old_value, new_value, changed_by
from change_history
where table_name = 'CUSTOMERS' and pk_value = '2'
order by history_id;
ACTION COLUMN_NAME   OLD_VALUE                    NEW_VALUE                CHANGED_BY
------ ------------- ---------------------------- ------------------------ ----------
UPDATE EMAIL         accounts@globex.example.com  ap@globex.example.com    FORMLAB
UPDATE CREDIT_LIMIT  25000                        30000                    FORMLAB

Two columns changed, so there are two rows. The other columns were not touched and are not logged. CHANGED_BY is FORMLAB, the database user, because this change was made in SQL, not in APEX.

Step 4: Show the History on the APEX Form

The demo application has a Customers report with a form (page 3), created with the Create App wizard. Open page 3 in Page Designer, right-click Content Body, and choose Create Region:

Page Designer with the context menu of Content Body and Create Region highlighted
Create a region below the form

Select the new region and set:

  • Identification, Name: Change History
  • Identification, Type: Classic Report
  • Source, Type: SQL Query
  • Source, Page Items to Submit: P3_CUSTOMER_ID

As the SQL Query, enter:

select h.changed_on,
       h.changed_by,
       initcap(h.action) as action,
       initcap(replace(h.column_name, '_', ' ')) as field,
       h.old_value,
       h.new_value
from change_history h
where h.table_name = 'CUSTOMERS'
and h.pk_value = :P3_CUSTOMER_ID
order by h.history_id desc
Change History region with Type Classic Report, the SQL query, and Page Items to Submit P3_CUSTOMER_ID
The region reads the history of the customer shown in the form

The query turns column names such as CREDIT_LIMIT into readable labels such as Credit Limit, and shows the newest change first. For another table, change the table name and the page item.

A new customer has no history yet, so show the region only for existing customers. In Server-side Condition, set Type to Item is NOT NULL and Item to P3_CUSTOMER_ID:

Server-side Condition of the Change History region set to Item is NOT NULL P3_CUSTOMER_ID
The region is shown only when the form edits an existing customer

Optionally, under Columns, give CHANGED_ON the format mask Mon DD, YYYY HH24:MI and set readable headings. Click Save.

The Result

In the demo, JOHN raised the credit limit of Acme Corporation from 50000 to 75000. Later, EMILY changed its phone number and put it on hold. Each of them only saved the form. When anyone opens Acme Corporation now, the form shows the full story, newest first, as in the screenshot at the top of this article.

Good to Know

  • The table needs a single-column primary key. ENABLE_HISTORY reads it from the table's primary key constraint.
  • CLOB, BLOB, and other large columns are not recorded. Values longer than 4,000 characters are cut.
  • Bulk changes create one history row per changed column and row, so a large data load also writes a large history. Turn history off for the load with change_history_api.disable_history('CUSTOMERS'), and on again with enable_history.
  • The history table grows forever unless you clean it up. A scheduled job that deletes rows older than, for example, two years keeps it small.
  • CHANGED_ON uses the database time zone, from SYSTIMESTAMP.
  • To see all changes of all tables, create an Interactive Report page on CHANGE_HISTORY and protect it with an authorization scheme for administrators.

Summary

A history table, a package of about 100 lines, and one call to change_history_api.enable_history give any table a full record of who changed what. A Classic Report region with one SQL query shows that record right on the APEX form, so the answer to "who changed this?" is one click away.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted
00