File Coverage

blib/lib/DBIx/Class/Events.pm
Criterion Covered Total %
statement 54 54 100.0
branch 15 16 93.7
condition 3 3 100.0
subroutine 11 11 100.0
pod 6 6 100.0
total 89 90 98.8


line stmt bran cond sub pod time code
1             package DBIx::Class::Events;
2              
3             # ABSTRACT: Store Events for your DBIC Results
4             our $VERSION = '0.9.2'; # VERSION
5              
6 1     1   790990 use v5.10;
  1         9  
7 1     1   4 use strict;
  1         2  
  1         16  
8 1     1   5 use warnings;
  1         1  
  1         32  
9 1     1   25 use parent 'DBIx::Class';
  1         3  
  1         7  
10              
11 1     1   65 use Carp;
  1         2  
  1         645  
12              
13             __PACKAGE__->mk_classdata( events_relationship => 'events' );
14              
15             sub event {
16 73     73 1 114251 my ($self, $event, $col_data) = @_;
17              
18             # Just calling $object->event shouldn't work
19 73 100       452 croak("Event is required") unless defined $event;
20              
21             my %col_data = (
22             $self->event_defaults($event, $col_data),
23              
24             # Ignore unknown columns when we enter the event.
25             # TODO: optimize the ->columns call
26 71         242 map { $_ => $col_data->{$_} }
27 72         197 grep { exists $col_data->{$_} }
  371         13854  
28             $self->result_source
29             ->related_source( $self->events_relationship )->columns,
30             );
31              
32 72         1266 return $self->create_related( $self->events_relationship,
33             { %col_data, event => $event } );
34             }
35              
36       72 1   sub event_defaults {}
37              
38             sub state_at {
39 17     17 1 46911 my ($self, $time_stamp, @args) = @_;
40              
41 17 100       66 if (ref $time_stamp) {
42 4         13 my $dtf = $self->result_source->schema->storage->datetime_parser;
43 4         198 $time_stamp = $dtf->format_datetime( $time_stamp );
44             }
45              
46 17         591 my $events = $self->search_related( $self->events_relationship );
47 17         7230 my $alias = $events->current_source_alias;
48             $events = $events->search( {
49             "$alias.event" => { in => [qw( insert update delete )] },
50             "$alias.triggered_on" => { '<=', $time_stamp },
51             },
52             {
53             select => [ "$alias.event", "$alias.details" ],
54             order_by => [
55 17         223 map {"$alias.$_ desc"} 'triggered_on',
  34         205  
56             $events->result_source->primary_columns
57             ],
58             } )->search(@args);
59              
60 17         16992 my $event = $events->next;
61 17 100 100     42035 return undef if !$event or $event->event eq 'delete';
62              
63 11         173 my %state;
64 11         25 while ($event) {
65 22 100       1369 %state = ( %{ $event->details || {} }, %state );
  22         407  
66              
67 22 100       8636 last if $event->event eq 'insert';
68 11         138 $event = $events->next;
69             }
70              
71 11         163 return \%state;
72             }
73              
74             sub insert {
75 22     22 1 365743 my ( $class, @args ) = @_;
76              
77 22         86 my $self = $class->next::method(@args);
78              
79 22         38428 my %inserted = $self->get_columns;
80 22         312 $self->event( insert => { details => \%inserted } );
81              
82 22         86786 return $self;
83             };
84              
85             sub update {
86 13     13 1 27107 my ( $self, @args ) = @_;
87              
88             # Do this here instead of letting our parent do it
89             # so that we can use get_dirty_columns.
90 13 100       76 $self->set_inflated_columns(@args) if @args;
91              
92 13         372 my %changed = $self->get_dirty_columns;
93              
94 13         160 $self->next::method(); # we already set_inflated_columns
95              
96 13 100       13207 $self->event( update => { details => \%changed } ) if %changed;
97              
98 13         37173 return $self;
99             };
100              
101             sub delete {
102 6     6 1 2173 my ( $self, @args ) = @_;
103              
104 6         39 my $ret = $self->next::method(@args);
105              
106             # DBIx::Class::Row::delete has a special edge case for calling
107             # delete as a class method, we however can't log it in that case.
108 6 50       17413 if ( ref $self ) {
109 6         33 my %deleted = $self->get_columns;
110 6         101 $self->event( delete => { details => \%deleted } );
111             }
112              
113 6         20117 return $ret;
114             };
115              
116             1;
117              
118             =pod
119              
120             =encoding UTF-8
121              
122             =head1 NAME
123              
124             DBIx::Class::Events - Store Events for your DBIC Results
125              
126             =head1 VERSION
127              
128             version 0.9.2
129              
130             =head1 SYNOPSIS
131              
132             my $artist
133             = $schema->resultset('Artist')->create( { name => 'Dead Salmon' } );
134             $artist->events->count; # is now 1, an 'insert' event
135              
136             $artist->change_name('Trout'); # add a name_change event
137             $artist->update; # An update event, last_name_change_id and name
138              
139             # Find their previous name
140             my $name_change = $artist->last_name_change;
141             print $name_change->details->{old}, "\n";
142              
143             See C<change_name> and C<last_name_change> example definitions
144             in L</CONFIGURATION AND ENVIRONMENT>.
145              
146             # Three more name_change events and one update event
147             $artist->change_name('Fried Trout');
148             $artist->change_name('Poached Trout in a White Wine Sauce');
149             $artist->change_name('Herring');
150             $artist->update;
151              
152             # Look up all the band's previous names
153             print "$_\n"
154             for map { $_->details->{old} }
155             $artist->events->search( { event => 'name_change' } );
156              
157             $artist->delete; # and then they break up.
158              
159             # We can find out now when they broke up, if we remember their id.
160             my $deleted_on
161             = $schema->resultset('ArtistEvent')
162             ->single( { artistid => $artist->id, event => 'delete' } )
163             ->triggered_on;
164              
165             # Find the state of the band was just before the breakup.
166             my $state_before_breakup
167             = $artist->state_at( $deleted_on->subtract( seconds => 1 ) );
168              
169             # Maybe this is common,
170             # so we have a column to link to who they used to be.
171             my $previous_artist_id = delete $state_before_breakup->{artistid};
172              
173             # Then we can form a new band, linked to the old,
174             # with the same values as the old band, but a new name.
175             $artist = $schema->resultset('Artist')->create( {
176             %{$state_before_breakup},
177             previousid => $previous_artist_id,
178             name => 'Red Herring',
179             } );
180              
181             # After a few more name changes, split-ups, and getting back together,
182             # we find an event we should have considered, but didn't.
183             my $death_event
184             = $artist->event( death => { details => { who => 'drummer' } } );
185              
186             # but, we then go back and modify it to note that it was only a rumor
187             $death_event->details->{only_a_rumour} = 1;
188             $death_event->make_column_dirty('details'); # changing the hashref doesn't
189             $death_event->update
190              
191             # And after even more new names and arguments, they split up again
192             $artist->delete;
193              
194             See L</CONFIGURATION AND ENVIRONMENT> for how to set up the tables.
195              
196             =head1 DESCRIPTION
197              
198             A framework for capturing events that happen to a Result in a table,
199             L</PRECONFIGURED EVENTS> are triggered automatically to track changes.
200              
201             This is useful for both being able to see the history of things
202             in the database as well as logging when events happen that
203             can be looked up later.
204              
205             Events can be used to track when things happen.
206              
207             =over
208              
209             =item when a user on a website clicks a particular button
210              
211             =item when a recipe was prepared
212              
213             =item when a song was played
214              
215             =item anything that doesn't fit in the main table
216              
217             =back
218              
219             =head1 CONFIGURATION AND ENVIRONMENT
220              
221             =head2 event_defaults
222              
223             A method that returns an even-sized list of default values that will be used
224             when creating a new event.
225              
226             my %defaults = $object->event_defaults( $event_type, \%col_data );
227              
228             The C<$event_type> is a string defining the "type" of event being created.
229             The C<%col_data> is a reference to the parameters passed in.
230              
231             No default values, but if your database doesn't set a default for
232             C<triggered_on>, you may want to set it to a C<< DateTime->now >> object.
233              
234             =head2 events_relationship
235              
236             An class accessor that returns the relationship to get from your object
237             to the relationship.
238              
239             Default is C<events>, but you can override it:
240              
241             __PACKAGE__->has_many(
242             'cd_events' =>
243             ( 'MyApp::Schema::Result::ArtistEvents', 'cdid' ),
244             { cascade_delete => 0 },
245             );
246              
247             __PACKAGE__->events_relationship('cd_events');
248              
249             =head2 Tables
250              
251             =head3 Tracked Table
252              
253             The table with events to be tracked in the L</Tracking Table>.
254              
255             It requires the Component and L</events_relationship> in the Result class:
256              
257             package MyApp::Schema::Result::Artist;
258             use base qw( DBIx::Class::Core );
259              
260             ...;
261              
262             __PACKAGE__->load_components( qw/ Events / );
263              
264             # A different name can be used with the "events_relationship" attribute
265             __PACKAGE__->has_many(
266             'events' => ( 'MyApp::Schema::Result::ArtistEvent', 'artistid' ),
267             { cascade_delete => 0 },
268             );
269              
270             You can also add custom events to track when something happens. For example,
271             you can create a method to add events when an artist changes their name:
272              
273             __PACKAGE__->add_column(
274             last_name_change_id => { data_type => 'integer' } );
275              
276             __PACKAGE__->has_one(
277             'last_name_change' => 'MyApp::Schema::Result::ArtistEvent',
278             { 'foreign.artisteventid' => 'self.last_name_change_id' },
279             { cascade_delete => 0 },
280             );
281              
282             sub change_name {
283             my ( $self, $new_name ) = @_;
284              
285             my $event = $self->event( name_change =>
286             { details => { new => $new_name, old => $self->name } } );
287             $self->last_name_change( $event );
288             # $self->update; # be lazy and make our caller call ->update
289              
290             $self->name( $new_name );
291             }
292              
293             =head3 Tracking Table
294              
295             This table holds the events for the L</Tracked Table>.
296              
297             The C<triggered_on> column must either provide a C<DEFAULT> value
298             or you should add a default to L</event_defaults>.
299              
300             package MyApp::Schema::Result::ArtistEvent;
301              
302             use warnings;
303             use strict;
304             use JSON;
305              
306             use base qw( DBIx::Class::Core );
307              
308             __PACKAGE__->load_components(qw/ InflateColumn::DateTime /);
309              
310             __PACKAGE__->table('artist_event');
311              
312             __PACKAGE__->add_columns(
313             artisteventid => { data_type => 'integer', is_auto_increment => 1 },
314             artistid => { data_type => 'integer' },
315              
316             # The type of event
317             event => { data_type => 'varchar' },
318              
319             # Any other custom columns you want to store for each event.
320              
321             triggered_on => {
322             data_type => 'datetime',
323             default_value => \'NOW()',
324             },
325              
326             # Where we store freeform data about what happened
327             details => { data_type => 'longtext' },
328             );
329              
330             __PACKAGE__->set_primary_key('artisteventid');
331              
332             # You should set up automatic inflation/deflation of the details column
333             # as it is used this way by "state_at" and the insert/update/delete
334             # events. Does not have to be JSON, just be able to serialize a hashref.
335             {
336             my $json = JSON->new->utf8;
337             __PACKAGE__->inflate_column( 'details' => {
338             inflate => sub { $json->decode(shift) },
339             deflate => sub { $json->encode(shift) },
340             } );
341             }
342              
343             # A path back to the object that this event is for,
344             # not required unlike the has_many "events" relationship above
345             __PACKAGE__->belongs_to(
346             'artist' => ( 'MyApp::Schema::Result::Artist', 'artistid' ) );
347              
348             You probably also want an index for searching for events:
349              
350             sub sqlt_deploy_hook {
351             my ( $self, $sqlt_table ) = @_;
352             $sqlt_table->add_index(
353             name => 'artist_event_idx',
354             fields => [ "artistid", "triggered_on", "event" ],
355             );
356             }
357              
358             =head1 PRECONFIGURED EVENTS
359              
360             Automatically creates Events for actions that modify a row.
361              
362             See the L</BUGS AND LIMITATIONS> of bulk modifications on events.
363              
364             =over
365              
366             =item insert
367              
368             Logs all columns to the C<details> column, with an C<insert> event.
369              
370             =item update
371              
372             Logs dirty columns to the C<details> column, with an C<update> event.
373              
374             =item delete
375              
376             Logs all columns to the C<details> column, with a C<delete> event.
377              
378             =back
379              
380             =head1 METHODS
381              
382             =head2 event
383              
384             Inserts a new event with L</event_defaults>:
385              
386             my $new_event = $artist->event( $event => \%params );
387              
388             First, the L</event_defaults> method is called to build a list of values
389             to set on the new event. This method is passed the C<$event> and a reference
390             to C<%params>.
391              
392             Then, the C<%params>, filtered for valid L</events_relationship> C<columns>,
393             are added to the C<create_related> arguments, overriding the defaults.
394              
395             =head2 state_at
396              
397             Takes a timestamp and returns the state of the thing at that timestamp as a
398             hash reference. Can be either a correctly deflated string or a DateTime
399             object that will be deflated with C<format_datetime>.
400              
401             Returns undef if the object was not C<in_storage> at the timestamp.
402              
403             my $state = $schema->resultset('Artist')->find( { name => 'David Bowie' } )
404             ->state_at('2006-05-29 08:00');
405              
406             An idea is to use it to recreate an object as it was at that timestamp.
407             Of course, default values that the database provides will not be included,
408             unless the L</event_defaults> method accounts for that.
409              
410             my $resurrected_object
411             = $object->result_source->new( $object->state_at($timestamp) );
412              
413             See ".. format a DateTime object for searching?" under L<DBIx::Class::Manual::FAQ/Searching>
414             for details on formatting the timestamp.
415              
416             You can pass additional L<search|DBIx::Class::ResultSet/search> conditions and
417             attributes to this method. This is done in context of searching the events
418             table:
419              
420             my $state = $object->state_at($timestamp, \%search_cond, \%search_attrs);
421              
422             =head1 BUGS AND LIMITATIONS
423              
424             There is no attempt to handle bulk updates or deletes. So, any changes to the
425             database made by calling
426             L<"update"|DBIx::Class::ResultSet/update> or L<"delete"|DBIx::Class::ResultSet/delete>
427             will not create events the same as L<single row|DBIx::Class::Row> modifications. Use the
428             L<"update_all"|DBIx::Class::ResultSet/update_all> or L<"delete_all"|DBIx::Class::ResultSet/delete_all>
429             methods of the C<ResultSet> if you want these triggers.
430              
431             There are three required columns on the L</events_relationship> table:
432             C<event>, C<triggered_on>, and C<details>. We should eventually make those
433             configurable.
434              
435             =head1 SEE ALSO
436              
437             =over
438              
439             =item L<DBIx::Class::AuditAny>
440              
441             =item L<DBIx::Class::AuditLog>
442              
443             =item L<DBIx::Class::Journal>
444              
445             =item L<DBIx::Class::PgLog>
446              
447             =back
448              
449             =head1 AUTHOR
450              
451             Grant Street Group <developers@grantstreet.com>
452              
453             =head1 COPYRIGHT AND LICENSE
454              
455             This software is Copyright (c) 2018 - 2019 by Grant Street Group.
456              
457             This is free software, licensed under:
458              
459             The Artistic License 2.0 (GPL Compatible)
460              
461             =head1 CONTRIBUTORS
462              
463             =for stopwords Andrew Fresh Brendan Byrd Justin Wheeler
464              
465             =over 4
466              
467             =item *
468              
469             Andrew Fresh <andrew.fresh@grantstreet.com>
470              
471             =item *
472              
473             Andrew Fresh <andrew+github@afresh1.com>
474              
475             =item *
476              
477             Brendan Byrd <brendan.byrd@grantstreet.com>
478              
479             =item *
480              
481             Justin Wheeler <justin.wheeler@grantstreet.com>
482              
483             =back
484              
485             =cut
486              
487             __END__
488              
489              
490             1;