The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

NAME

Class::ReluctantORM::Audited - CRO clases with Audit Logging

SYNOPSIS

  package ImportantThing;
  use strict;
  use warnings;
  use base 'Class::ReluctantORM::Audited';

  __PACKAGE__->build_class(
       # Normal Class::ReluctantORM->build_class options
       table => 'important_things',
       schema => 'stuff',
       primary_key => 'thing_id',
       ...

       # Additional audit table options
       audit_table_name => 'important_changes',
       audit_schema_name => 'audit',
       audit_primary_key => 'change_id', # Or array ref
       audit_columns => [qw(audit_user_id audit_ip_address)],
       audit_insert => 0, # also delete, update
       audit_seamless_mode => 1, # hijack old method names, make purists cry
  );

  # Two ways to provide audit metadata:
  $thing->audited_update($user_id, $ip_address);

  # or magically, if you define
  # ImportantThing::get_audit_metadata_audit_user_id and
  # ImportantThing::get_audit_metadata_audit_ip_address
  $thing->audited_update();

  # To make update() explode, use audit_seamless_mode => 0 (default)

  # this still works because of audit_seamless_mode => 1
  # but you must have the get_audit_data methods
  $thing->update();

DESCRIPTION

Many times it is neccesary to log changes to tables. This log, called an audit log, typically retains the previous state of the row, along with the action that was performed, and any additional metadata deemed needed by the business (such as who made the change). This class exists to make such work easier.

This is a Class::ReluctantORM subclass. All its methods are available here. save(), update(), insert(), create(), and delete() are re-defined in this class.

CREATING AN AUDIT TABLE

This class expects there to be a separate table for logging changes made against each orginal table. It may be in any schema, and have any name. No CRO triggers, DB triggers or stored procedures are used.

To create your audit table, create a new table with the same column and same datatypes as the original table, with the following modifications:

Do not make your original primary key(s) unique. You may wish to add an additional, artificial primary key to the log table; you can identify that column(s) using the audit_primary_key option.
Drop any other unique constraints.
Add a column, of type text, named "audit_action". This module will store the name of the action that occurred in this column.
If you are auditing INSERTS, all original columns must be made nullable in the audit table.
It is your decision as to whether or not to enforce foreign key constraints; most users choose not to.
Add any audit metadata columns, for example, the IP address of the user makeing the change. If the audit metadata column has a default, you need not list it in the audit_columns option to build_class. Note that your audit column names don't have to start with 'audit_', but is a nice convention. CRO obtains the list of audit columns from build_class.

PROVIDING AUDIT METADATA

The audit metadata must be provided "out of band" - it is not part of the actual update/insert/delete, but must be provided for auditing. To support this, two approaches are available.

Passing Audit Metadata as Arguments to audited_update, etc

You may provide the metadata directly when calling audited_update, audited_delete, audited_insert, or audited_save. (audited_create is handled specially). You must provide all values, and you must provide them in the order specified in build_class.

   $thing->audited_update($changing_user_id, $ip_address);

For audited_create, you may provide the audit metadata as additional columns. Your audit column names must be distinct from your original column names for this to work.

   ImportantThing->audited_create(
       name => 'whatever',
       other_field => $stuff,
       ...
       audit_user_id => $changing_user_id,
       audit_ip_address => $ip_address,
   );

The downside of this approach is that your calling code must provide these values every time it performs an audited operation, which becomes tiresome.

Providing Audit Metadata Generators

Alternatively, you can provide instance methods in your subclass that will obtain each piece of metadata when performing an audited action. The methods should have names that begin with 'get_audit_metadata_FIELDNAME':

  sub get_audit_metadata_audit_user_id {
     my $self = shift;
     # magically determine $changing_user_id from globals or $self
     return $changing_user_id;
  }

With that (and any other generators in place) you can now simply call:

  $thing->audited_update();

SEAMLESS MODE

You may be wondering what happens now when someone calls, for example, $thing->update(). You have your choice of poisons.

Seamless Mode Off (Default)

Calling the plain version of any audited action will throw an exception. You are required to call the audited_whatever version.

This is more more "pure", in terms of software design. By changing the preconditions for calling a method, we have violated a contract, and we should let the user know it's no longer safe to call it the old way.

The downside is that you now have to retrofit all old code to call audited_whatever. You'll also need to arrange to have the audit metadata available at each of these invocations.

Seamless Mode On

Calling the plain version of any audited action will attempt to obtain the audit metadata, then hand off to the audited version.

This means you can do this:

  $thing->update($changing_user_id, $ip_address);

  # Or if youve defined metatdata generator methods, simply
  $thing->update();

That's a lot of magic going on there. This may lead to unexpected suprises (especially if a metatdata generator fails), but the upside is that you might not have to change any code.

METHODS

YourSubClass->build_class(%audit_opts, %cro_opts)

Sets up your class for auditing. Accepts all options that the stock Class::ReluctantORM build_class accepts (in any order). In addition, the following auditing options are supported:

audit_schema_name

Required. Name of the schema in the database for the audit table.

audit_table_name

Required. Name of audit table in the database. For details on how this table should be constructed, see CREATING AN AUDIT TABLE.

audit_primary_key

Optional. String, or arrayref of strings, identifying an optional primary key on the log table itself. This allows you to call last_audit_primary_key_value() on your original object, and thus identify individual log entries.

audit_columns

Optional arrayref of strings. These are extra columns (not present in the original table) that you would also like to include in the log (for example, IP address). For details on how to pass these values, see PROVIDING AUDIT COLUMN VALUES.

audit_inserts, audit_updates, audit_deletes

Optional booleans, default true. You may set any of these to 0 to disable auditing on that action. Disabling all of them disables auditing.

audit_seamless_mode

Boolean, default false. If you provide a true value, seamless mode is enabled. See SEAMLESS MODE above.

$bool = YourSubClass->is_audited();

Always returns true. Plain Class::ReluctantORM classes always return false.

@col_names = YourSubClass->audit_columns();

Returns an array of column names that are treated as audit metadata columns. This array is obtained directly from build_class().

@col_names = YourSubClass->audit_primary_key_columns()

If your audit table has a primary key (for change tracking, etc), this will return the column names that make up the primary key.

Returns an empty list if you did not pass a value for audit_primary_key to build_class.

$bool = YourSubClass->audit_seamless_mode();

Returns true if seamless mode is enabled for this class.

$bool = YourSubClass->audit_inserts();

Returns true if inserts (and creates) are being audited.

$bool = YourSubClass->audit_updates();

Returns true if updates are being audited.

$bool = YourSubClass->audit_deletes();

Returns true if deletes are being audited.

$str = YourSubClass->audit_schema_name();

Returns the name of the schema that the audit table lives in.

$str = YourSubClass->audit_table_name();

Returns the name of the audit table in the database.

$val = $obj->last_audit_primary_key_value()

$array_ref = $obj->last_audit_primary_key_value()

@array = $obj->last_audit_primary_key_value()

If your audit table has a primary key, this will return the primary value of the last log entry.

If your audit table has a single-column primary key, the one value will be returned.

If your audit table has a multi-column primary key, an arrayref of the values, in the same order you used in build_class, will be returned.

In list context, an array is alwas returned.

If no audited action has occurred, it will return undef or an empty list.

AUDITING CRUD METHODS

For each of these methods, the arguments are optional if you have defined metadata generator methods.

$obj->audited_update(@metadata);

Copies the existing row in the database to the audit table along with the metadata, then performs the update on the original table.

$obj->audited_insert(@metadata);

Performs the insert on the original table, then copies the new primary key and the metadata to the audit table (all other columns are left NULL, as this is a new row and the audit table only records past states).

$obj->audited_delete(@metadata);

Copies the existing data from the original table along with the metadata to the audit table, then performs the delete on the original table.

$obj->audited_save(@metadata);

Performs either an audited insert or an audited_update, depending on whether the object has been saved to the database.

NOTE: This does NOT call the before_save_trigger nor the after_save_trigger, due to a sequencing conflict. We're looking for ways around that, but keep in mind the insert and update triggers will be called normally.

$obj = YourSubClass->audited_create(%yourfields, %audit_fields);

Like Class::ReluctantORM's create(), creates a new object in memory and immediately commits it to the database. Here, you may also specify the audit metadata as well, which will get separated out.

BUGS AND LIMITATIONS

Does not call the save() triggers.
Audited inserts fill your table with mostly NULL rows.

AUTHOR

  Clinton Wolfe clinton@omniti.com