File Coverage

blib/lib/Class/DBI/Audit.pm
Criterion Covered Total %
statement 103 106 97.1
branch 25 40 62.5
condition 16 25 64.0
subroutine 21 22 95.4
pod 6 6 100.0
total 171 199 85.9


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Class::DBI::Audit - Audit changes to columns in CDBI objects.
4              
5             =head1 SYNOPSIS
6              
7             # Base class
8             package Music::DBI;
9             use base 'Class::DBI';
10             use mixin 'Class::DBI::Audit';
11             Music::DBI->connection('dbi:mysql:dbname', 'username', 'password');
12             __PACKAGE__->auditColumns({
13             remote_user => [ from_hash =>
14             { name => 'ENV', key => 'REMOTE_USER' } ],
15             time_stamp => [ from_sub =>
16             { subroutine => sub { scalar localtime; } } ]
17             });
18              
19             # Derived class
20             package Music::Artist;
21             use base 'Music::DBI';
22             __PACKAGE__->table('artist');
23             __PACKAGE__->auditTable('artist_audit');
24             __PACKAGE__->columns(All => qw/artistid first_name last_name/);
25             __PACKAGE__->columns(Audit => qw/first_name last_name/);
26             __PACKAGE__->add_audit_triggers;
27            
28             # (or everything can go in the base or derived class, if you want)
29              
30             /*
31             * Now create an audit table, to track changes to first + last names
32             * of artists :
33             */
34             create table artist_audit (
35             id int unsigned NOT NULL auto_increment primary key,
36              
37             /* These 5 columns are mandatory */
38             parent_id int unsigned NOT NULL,
39             query_type enum('update','insert','delete'),
40             column_name varchar(255),
41             value_before blob,
42             value_after blob,
43              
44             /* The rest reflect auditColumns (above) */
45             time_stamp datetime,
46             remote_user varchar(255)
47             );
48              
49             # Then in your main program :
50              
51             $ENV{REMOTE_USER} = 'Puff'
52             $artist = Music::Artist->insert({
53             first_name => 'Jennifer',
54             last_name => 'Lopez' });
55              
56             $ENV{REMOTE_USER} = 'Ben'
57             $artist->first_name('J');
58             $artist->last_name('Lo');
59             $artist->update;
60              
61             for my $column (qw(first_name last_name)) {
62             for ($artist->column_history($column)) {
63             print $_->{remote_user}.
64             " set $column to ".
65             $_->{value_after}.
66             "\n";
67             }
68             }
69             # Puff set first_name to Jennifer
70             # Ben set first_name to J
71             # Puff set last_name to Jennifer
72             # Ben set last_name to Lo
73              
74             =head1 DESCRIPTION
75              
76             This module allows easy tracking of changes to values in tables
77             managed by CDBI classes. It helps you answer the question
78             "who set that value to be 'foobar', I thought I set it to
79             be 'farbar'?" without resorting to digging through snapshots of
80             your database tables and comparing them to your webserver's
81             http logs.
82              
83             Use this module as a mixin with either your base CDBI class, or
84             a derived one, and the following methods will be added to
85             your class (or classes) :
86              
87             auditTable()
88             auditColumns()
89             add_audit_triggers()
90             column_history()
91              
92             The first two specify the external audit table, ('artist_audit' above),
93             and the columns of this table (time_stamp and remote_user above).
94              
95             The third method adds the necessary triggers to your CDBI class which
96             will track the changes, writing them to the auditTable.
97              
98             The fourth returns a history of changes to a column (i.e. the
99             data from the audit table) as an array of hashrefs.
100              
101             Only columns in the 'Audit' group are audited. Set this
102             like so :
103              
104             __PACKAGE__->columns(Audit => qw/first_name last_name);
105              
106             You can use either one huge audit table for all of the classes
107             you wish to audit (in which case you'll want 'table' to be
108             an element of the auditColumns, see below), or you can have separate
109             audit tables for each class. Or some combination. Since audit
110             tables get big quickly, you'll probably want several tables.
111              
112             =head1 METHODS
113              
114             =over
115              
116             =cut
117              
118             package Class::DBI::Audit;
119 2     2   441954 use Carp;
  2         6  
  2         196  
120 2     2   2021 use mixin::with 'Class::DBI';
  2         4484  
  2         20  
121 2     2   234402 use SQL::Abstract;
  2         43737  
  2         107  
122 2     2   29 use strict;
  2         5  
  2         89  
123 2     2   13 use warnings;
  2         4  
  2         1738  
124             our $VERSION=0.04;
125              
126             =item auditColumns
127              
128             Set this class data to be a hash which specifies what goes in your
129             audit table, e.g.
130              
131             __PACKAGE__->auditColumns({
132             # hash from column name to where it comes from
133             remote_addr => [ from_hash => { name => 'ENV',
134             key => 'REMOTE_ADDR' } ],
135             remote_user => [ from_hash => { name => 'ENV',
136             key => 'REMOTE_USER' } ],
137             request_uri => [ from_hash => { name => 'ENV',
138             key => 'REQUEST_URI' } ],
139             command => [ from_scalar => { name => '0', } ],
140             table => [ from_method => { name => 'table' } ],
141             time_stamp => [ from_sub => { subroutine => sub {
142             strftime("%Y-%m-%d %H:%M:%S",localtime)
143             } } ]
144             });
145              
146             ...means store these values :
147              
148             $ENV{REMOTE_ADDR},
149             $ENV{REMOUTE_USER},
150             $ENV{REQUEST_URI},
151             $0,
152             $self->table,
153             the value returned by the anonymous subroutine
154             sub { strftime("%Y-%m-%d %H:%M:%S",localtime) }
155              
156             in columns named remote_addr, remote_user, remote_uri, command, table,
157             and time_stamp respectively.
158              
159             from_hash and from_scalar columns both look in the 'main::' symbol
160             table for their variables, override this with a 'package' entry if
161             desired.
162              
163             =cut
164              
165             __PACKAGE__->mk_classdata( auditColumns => { } );
166              
167             =item auditTable
168              
169             By default the audit table is the name of the CDBI table
170             with '_audit' appended to the end. Change this by calling
171             auditTable(). If multiple tables are using the same database
172             table for auditing, you'll want to give 'table' as one
173             of the methods in auditColumns (so you can tell what table
174             a row in the audit table refers to).
175              
176             =cut
177              
178             __PACKAGE__->mk_classdata( auditTable => undef );
179              
180             #
181             # Private functions (mixin.pm ignores them, so they
182             # aren't class methods)
183             #
184             sub _audit_table {
185 16     16   1555 my $obj = shift;
186 16   33     72 return $obj->auditTable || join '_', $obj->table, 'audit';
187             }
188              
189             # taken from the man page for DBI, as an example of prepare_cached
190             sub _insert_hash {
191 12     12   69 my ($dbh,$table, $field_values) = @_;
192 12 50       113 Carp::cluck("adding audit data for $table but we are not in a transaction") if $dbh->{AutoCommit};
193             # sort to keep field order, and thus sql, stable for prepare_cached
194 12         143 my @fields = sort keys %$field_values;
195 12         29 my @values = @{$field_values}{@fields};
  12         74  
196 12         158 my $sql = sprintf "insert into %s (%s) values (%s)",
197             $table, join(",", @fields), join(",", ("?")x@fields);
198 12         570 my $sth = $dbh->prepare_cached($sql);
199 12         784 return $sth->execute(@values);
200             }
201              
202             sub _do_query {
203 22     22   3766 my %args = @_;
204 22         85 my ($dbh,$where,$columns, $table) = @args{qw(dbh where columns table)};
205 22         150 my ($where_clause,@bind) = SQL::Abstract->new->where($where);
206 22         5972 my $sql = 'select ' .
207             ( join ',', @$columns ) .
208             " from ". $table .
209             $where_clause;
210 22 50       6101 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
211 22         3234 $sth->execute(@bind);
212 22         2967 return $sth;
213             }
214              
215             sub _values_differ {
216 24     24   41 my ($x,$y) = @_;
217 24 100 100     153 return 1 if defined($x) && !defined($y);
218 23 50 66     107 return 1 if defined($y) && !defined($x);
219 23 50 66     67 return 0 if !defined($x) && !defined($y);
220 21 100       97 return 0 if $x eq $y;
221 6 50 66     59 return 0 if (
      66        
222             $x=~(/^-?(?:\d+(?:\.\d*)?|\.\d+)$/) # from perldoc -q number
223             && $y=~(/^-?(?:\d+(?:\.\d*)?|\.\d+)$/) && $x==$y);
224 6 50 66     39 return 0 if $x=~/^\s*$/ && $y=~/^\s*$/; # ignore whitespace changes
225 6         18 return 1;
226             }
227              
228             sub _get_from_hash {
229 36     36   46 my $s = shift;
230 36   50     142 my $package = $s->{package} || 'main::';
231 36 50       257 defined(my $var = $s->{name}) or die "missing name for from_hash";
232 36 50       87 defined(my $key = $s->{key}) or die "missing key for from_hash";
233 36         37 my $val;
234             {
235 2     2   15 no strict 'refs';
  2         4  
  2         348  
  36         37  
236 36         61 $val = ${ $package . $var }{$key};
  36         146  
237             }
238 36         202 return $val;
239             }
240              
241             sub _get_from_scalar {
242 12     12   21 my $s = shift;
243 12   50     53 my $package = $s->{package} || 'main::';
244 12 50       34 defined(my $var = $s->{name}) or die "missing name for from_scalar";
245 12         13 my $val;
246             {
247 2     2   13 no strict 'refs';
  2         5  
  2         3041  
  12         15  
248 12         12 $val = ${ $package . $var };
  12         49  
249             }
250 12         62 return $val;
251             }
252              
253             sub _get_from_method_call {
254 0     0   0 my ($s,$obj) = @_;
255 0 0       0 my $name = $s->{name} or die "missing method name for method_call";
256 0         0 return $obj->$name;
257             }
258              
259             sub _get_from_sub {
260 12     12   105 my $s = shift;
261 12         49 return $s->{subroutine}->();
262             }
263              
264             sub _audit_column_values {
265 12     12   348 my $obj = shift;
266             # Returns a hash from column name to value.
267 12         18 my %spec = %{ $obj->auditColumns };
  12         44  
268 12         267 my %h = ();
269 12         54 while ( my ( $column_name, $how ) = each %spec ) {
270 60 50       198 ref($how) eq 'ARRAY' or die "bad auditColumns spec for $column_name";
271 60 50       274 $h{$column_name} =
    50          
    100          
    100          
272             $how->[0] eq 'from_hash' ? _get_from_hash( $how->[1] )
273             : $how->[0] eq 'from_scalar' ? _get_from_scalar( $how->[1] )
274             : $how->[0] eq 'from_method' ? _get_from_method_call($how->[1], $obj )
275             : $how->[0] eq 'from_sub' ? _get_from_sub($how->[1])
276             : die("unknown column specification: $how->[0]");
277             }
278 12 50       61 defined($h{parent_id} = $obj->id) or Carp::confess("no parent id");
279 12         1649 return %h;
280             }
281              
282             =item add_audit_triggers
283              
284             Adds all the triggers below to a class.
285              
286             =cut
287              
288             sub add_audit_triggers {
289 1     1 1 2715340 my $class = shift;
290 1         13 $class->add_trigger(after_create => \&Class::DBI::Audit::after_create);
291 1         73 $class->add_trigger(before_update => \&Class::DBI::Audit::before_update);
292 1         36 $class->add_trigger(after_update => \&Class::DBI::Audit::after_update);
293 1         45 $class->add_trigger(before_delete => \&Class::DBI::Audit::before_delete);
294             }
295              
296             =item after_create
297              
298             A subroutine to be used in the after_create trigger.
299              
300             =cut
301              
302             sub after_create {
303 1     1 1 189010 my $obj = shift;
304 1         6 my $new = _do_query(
305             dbh => $obj->db_Main(),
306             table => $obj->table,
307             columns => [ $obj->columns('Audit') ],
308             where => { $obj->primary_column => $obj->id }
309             )->fetch_hash;
310 1         83 for my $column ( $obj->columns('Audit') ) {
311 3         2641 my $val = $new->{$column};
312 3 100       45 next unless defined($val);
313 2         15 _insert_hash(
314             $obj->db_Main(),
315             _audit_table($obj),
316             {
317             query_type => 'insert',
318             column_name => $column,
319             value_after => $val,
320             _audit_column_values($obj),
321             }
322             );
323             }
324             }
325              
326             =item before_update
327              
328             A subroutine to be used in the before_update trigger.
329              
330             =cut
331              
332             sub before_update {
333 8     8 1 1106018 my $obj = shift;
334 8         37 my $old = _do_query(
335             dbh => $obj->db_Main(),
336             table => $obj->table,
337             columns => [ $obj->columns('Audit') ],
338             where => { $obj->primary_column => $obj->id }
339             )->fetch_hash;
340 8         608 $obj->_attribute_set( _audit_fields_old => $old );
341             }
342              
343             =item after_update
344              
345             To be used in the after_update trigger.
346              
347             =cut
348              
349             sub after_update {
350 8     8 1 16511 my $obj = shift;
351 8         135 my ($old) = $obj->_attrs(qw(_audit_fields_old));
352 8         81 my $new = _do_query(
353             dbh => $obj->db_Main(),
354             table => $obj->table,
355             columns => [ $obj->columns('Audit') ],
356             where => { $obj->primary_column => $obj->id }
357             )->fetch_hash;
358 8         916 for my $column ($obj->columns('Audit')) {
359 24         2358 my $new_val = $new->{$column};
360 24         233 my $old_val = $old->{$column};
361 24 100       217 next unless _values_differ($new_val,$old_val);
362 7         28 _insert_hash(
363             $obj->db_Main(),
364             _audit_table($obj),
365             {
366             query_type => 'update',
367             column_name => $column,
368             value_before => $old_val,
369             value_after => $new_val,
370             _audit_column_values($obj),
371             }
372             );
373             }
374             }
375              
376             =item before_delete
377              
378             To be used in the before_delete trigger.
379              
380             =cut
381              
382             sub before_delete {
383 1     1 1 2424 my $obj = shift;
384 1         7 my $old = _do_query(
385             dbh => $obj->db_Main(),
386             table => $obj->table,
387             columns => [ $obj->columns('Audit') ],
388             where => { $obj->primary_column => $obj->id }
389             )->fetch_hash;
390              
391 1         56 for my $column ($obj->columns('Audit')) {
392 3         804 _insert_hash(
393             $obj->db_Main(),
394             _audit_table($obj),
395             {
396             _audit_column_values($obj),
397             column_name => $column,
398             query_type => 'delete',
399             value_before => $old->{$column},
400             });
401             }
402             }
403              
404             =item column_history
405              
406             Fetch the history of a column from the audit table.
407             Returns an array of hashrefs whose keys correspond
408             to the values in the audit table.
409              
410             =cut
411              
412             sub column_history {
413 4     4 1 1385097 my ( $obj, $column ) = @_;
414 4         39 my @vals = _do_query(
415             dbh => $obj->db_Main(),
416             columns => [ '*' ],
417             where => { parent_id => $obj->id, column_name => $column },
418             table => _audit_table($obj)
419             )->fetchall_hash;
420 4         932 return @vals;
421             }
422              
423             =back
424              
425             =head1 NOTES
426              
427             Data in the audit table is always added, never deleted or
428             changed. Some databases may be optimized for such tables
429             (e.g. the MySQL "archive" engine)
430              
431             If a field with just whitespace is changed to another field
432             with just whitespace, this is ignored. (But NULLs changing
433             to not NULLs and vice versa are logged.)
434              
435             If a field that looks like a number is changed to another one that looks like a
436             number with the same value, this is ignored. See _values_differ() in
437             the source code.
438              
439             Most likely, value_before and value_after will have some redundancy (since the
440             next value_before should be the previous value_after); this is intentional,
441             since it'll cause any non-audited changes to the cdbi table to show up.
442              
443             All the triggers get data directly from the database using the primary key + table +
444             primary key value. This is to avoid side effects (e.g. accidentally populating
445             some fields of the object), and to ensure that the audit tables contain a record
446             of the actual data in the table, rather than anything in memory, or anything
447             that was inflated or filtered via select triggers.
448              
449             =head1 TODO
450              
451             Provide a mechanism for overriding _values_differ().
452              
453             =cut
454              
455             1;
456