File Coverage

blib/lib/App/JESP.pm
Criterion Covered Total %
statement 149 158 94.3
branch 27 32 84.3
condition 5 9 55.5
subroutine 27 27 100.0
pod 3 3 100.0
total 211 229 92.1


line stmt bran cond sub pod time code
1             package App::JESP;
2             $App::JESP::VERSION = '0.014';
3 8     8   586868 use Moose;
  8         3082643  
  8         54  
4              
5 8     8   53216 use App::JESP::Plan;
  8         40  
  8         290  
6 8     8   2977 use App::JESP::Colorizer;
  8         2678  
  8         278  
7              
8 8     8   3500 use JSON;
  8         52955  
  8         43  
9 8     8   962 use Class::Load;
  8         15  
  8         253  
10 8     8   8249 use DBI;
  8         98286  
  8         440  
11 8     8   3002 use DBIx::Simple;
  8         29451  
  8         237  
12 8     8   56 use File::Spec;
  8         16  
  8         146  
13 8     8   2144 use IO::Interactive;
  8         42046  
  8         47  
14 8     8   2618 use Log::Any qw/$log/;
  8         44954  
  8         37  
15              
16             # Settings
17             ## DB Connection attrbutes.
18             has 'dsn' => ( is => 'ro', isa => 'Str', required => 1 );
19             has 'username' => ( is => 'ro', isa => 'Maybe[Str]', required => 1);
20             has 'password' => ( is => 'ro', isa => 'Maybe[Str]', required => 1);
21             has 'home' => ( is => 'ro', isa => 'Str', required => 1 );
22              
23             ## JESP Attributes
24             has 'prefix' => ( is => 'ro', isa => 'Str', default => 'jesp_' );
25             has 'driver_class' => ( is => 'ro', isa => 'Str', lazy_build => 1);
26              
27             # Operational stuff
28             has 'get_dbh' => ( is => 'ro', isa => 'CodeRef', default => sub{
29             my ($self) = @_;
30             return sub{
31             return DBI->connect( $self->dsn(), $self->username(), $self->password(),
32             { RaiseError => 1,
33             PrintError => 0,
34             AutoCommit => 1,
35             });
36             };
37             });
38              
39             has 'dbix_simple' => ( is => 'ro', isa => 'DBIx::Simple', lazy_build => 1);
40             has 'patches_table_name' => ( is => 'ro', isa => 'Str' , lazy_build => 1);
41             has 'meta_patches' => ( is => 'ro', isa => 'ArrayRef[HashRef]',
42             lazy_build => 1 );
43              
44              
45             has 'plan' => ( is => 'ro', isa => 'App::JESP::Plan', lazy_build => 1);
46             has 'driver' => ( is => 'ro', isa => 'App::JESP::Driver', lazy_build => 1 );
47              
48             has 'interactive' => ( is => 'ro' , isa => 'Bool' , lazy_build => 1 );
49             has 'colorizer' => ( is => 'ro', isa => 'App::JESP::Colorizer', lazy_build => 1 );
50              
51             has json => ( is => "ro", lazy_build => 1 );
52 3     3   108 sub _build_json { JSON->new->relaxed(1) }
53              
54             sub _build_driver{
55 1     1   2 my ($self) = @_;
56 1         27 return $self->driver_class()->new({ jesp => $self });
57             }
58              
59             sub _build_driver_class{
60 1     1   2 my ($self) = @_;
61 1         19 my $db_name = $self->dbix_simple()->dbh()->{Driver}->{Name};
62 1         19 my $driver_class = 'App::JESP::Driver::'.$db_name;
63 1         5 $log->info("Loading driver ".$driver_class);
64 1         8 Class::Load::load_class( $driver_class );
65 1         54 return $driver_class;
66             }
67              
68             sub _build_plan{
69 4     4   9 my ($self) = @_;
70 4         85 my $file = File::Spec->catfile( $self->home(), 'plan.json' );
71 4 100 66     104 unless( ( -e $file ) && ( -r $file ) ){
72 1         11 die "File $file does not exists or is not readable\n";
73             }
74 3         94 return App::JESP::Plan->new({ file => $file, jesp => $self });
75             }
76              
77             sub _build_dbix_simple{
78 3     3   7 my ($self) = @_;
79 3         62 my $dbh = $self->get_dbh()->();
80 3         22929 my $db = DBIx::Simple->connect($dbh);
81             }
82              
83             sub _build_patches_table_name{
84 2     2   5 my ($self) = @_;
85 2         46 return $self->prefix().'patch';
86             }
87              
88             # Building the meta patches, in SQLite compatible format.
89             sub _build_meta_patches{
90 2     2   6 my ($self) = @_;
91             return [
92 2         40 { id => $self->prefix().'meta_zero', sql => 'CREATE TABLE '.$self->patches_table_name().' ( id VARCHAR(512) NOT NULL PRIMARY KEY, applied_datetime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );' }
93             ];
94             }
95              
96             sub _build_interactive{
97 2     2   6 my ($self) = @_;
98 2         10 return IO::Interactive::is_interactive();
99             }
100              
101             sub _build_colorizer{
102 2     2   5 my ($self) = @_;
103 2         58 return App::JESP::Colorizer->new({ jesp => $self });
104             }
105              
106             sub install{
107 3     3 1 477 my ($self) = @_;
108              
109             # First try to select from $self->patches_table_name
110 3         79 my $dbh = $self->dbix_simple->dbh();
111 3         13 my $patches = eval{
112             $self->_protect_select(
113 3     3   64 sub{ $self->dbix_simple()->query('SELECT '.$dbh->quote_identifier('id').' FROM '.$dbh->quote_identifier($self->patches_table_name)); },
114 3         20 "CANNOT_SELECT_FROM_META");
115             };
116 3 100       275 if( my $err = $@ ){
117 2 50       12 unless( $err =~ /^CANNOT_SELECT_FROM_META\n/ ){
118 0         0 $log->critical("Unexpected error from _protect_select. Run again in verbose mode.");
119 0         0 die $err;
120             }
121 2         11 $log->info("Innitiating meta tables");
122 2         59 $self->_apply_meta_patch( $self->meta_patches()->[0] );
123             }
124 3         17 $log->info("Uprading meta tables");
125             # Select all meta patches and make sure all of mine are applied.
126 3         94 my $applied_patches = { $self->dbix_simple()
127             ->select( $self->patches_table_name() , [ 'id', 'applied_datetime' ] , { id => { -like => $self->prefix().'meta_%' } } )
128             ->map_hashes('id')
129             };
130 3         1641 foreach my $meta_patch ( @{ $self->meta_patches() } ){
  3         362  
131 4 100       14 if( $applied_patches->{$meta_patch->{id}} ){
132 3         20 $log->debug("Patch ".$meta_patch->{id}." already applied on ".$applied_patches->{$meta_patch->{id}}->{applied_datetime});
133 3         11 next;
134             }
135 1         5 $self->_apply_meta_patch( $meta_patch );
136             }
137 3         12 $log->info("Done upgrading meta tables");
138 3         20 return 1;
139             }
140              
141             sub _applied_patches{
142 8     8   12 my ($self) = @_;
143              
144 8         151 my $db = $self->dbix_simple();
145             my $applied_patches_result = $self->_protect_select(
146             sub{
147 8     8   167 $db->select( $self->patches_table_name() , [ 'id', 'applied_datetime' ] , {} , 'applied_datetime' );
148 8         39 }, "ERROR querying meta schema. Did you forget to run 'install'?");
149              
150 7         30 return { $applied_patches_result->map_hashes('id') };
151             }
152              
153             sub status{
154 3     3 1 11 my ($self) = @_;
155 3         65 my $plan_patches = $self->plan()->patches();
156 3         18 $log->debug("Got ".scalar(@$plan_patches)." patches to inspect");
157              
158 3         13 my $applied_patches = $self->_applied_patches();
159              
160 3         474 foreach my $plan_patch ( @$plan_patches ){
161 11 100       231 if( my $applied_patch = delete $applied_patches->{$plan_patch->id()} ){
162 7         147 $plan_patch->applied_datetime( $applied_patch->{applied_datetime} );
163             }
164             }
165              
166 3         58 my $meta_prefix = $self->prefix().'meta';
167              
168             return {
169             plan_patches => $plan_patches,
170 3         13 plan_orphans => [ grep{ $_ !~ /^$meta_prefix/ } keys %$applied_patches ]
  4         37  
171             };
172             }
173              
174             sub deploy{
175 5     5 1 2690 my ($self, $options) = @_;
176              
177 5 100       16 defined($options) ||( $options = {} );
178              
179 5         21 $log->info("DEPLOYING DB Patches");
180              
181 5         134 my $db = $self->dbix_simple();
182 5         95 my $patches = $self->plan()->patches();
183              
184 5 100       12 if( $options->{patches} ){
185             # Filter existing patches.
186 1         2 my @patch_ids = @{$options->{patches}};
  1         4  
187 1         4 $log->info("Applying only patches ".join(', ', map{ "'".$_."'" } @patch_ids)." in this order");
  1         7  
188 1         5 my $patches_by_id = { map{ $_->id() => $_ } @$patches };
  4         92  
189 1         3 my @new_patch_list = ();
190 1         3 foreach my $patch_id ( @patch_ids ){
191 1         3 my $patch = $patches_by_id->{$patch_id};
192 1 50       3 unless( $patch ){
193 0         0 die "Cannot find patch '$patch_id' in the plan ".$self->plan()->file().". Check the name\n";
194             }
195 1         3 push @new_patch_list , $patch;
196             }
197 1         3 $patches = \@new_patch_list;
198             }
199              
200 5         13 my $applied_patches = $self->_applied_patches();
201              
202 4         602 my $applied = 0;
203 4         5 foreach my $patch ( @{$patches} ){
  4         9  
204 13 100       271 if( my $applied_patch = $applied_patches->{$patch->id()}){
205 9 100       21 unless( $options->{force} ){
206 4         109 $log->debug("Patch '".$patch->id()."' has already been applied on ".$applied_patch->{applied_datetime}." skipping");
207 4         13 next;
208             }
209 5         88 $log->warn("Patch '".$patch->id()."' has already been applied on ".$applied_patch->{applied_datetime}." but forcing it.");
210             }else{
211 4         73 $log->info("Patch '".$patch->id()."' never applied");
212             }
213 9         28 eval{
214 9         27 $db->begin_work();
215 9 100       319 if( my $already_applied = $db->select( $self->patches_table_name(), '*',
216             { id => $patch->id() } )->hash() ){
217 5         1269 $db->update( $self->patches_table_name(),
218             { applied_datetime => \'CURRENT_TIMESTAMP' },
219             { id => $patch->id() } );
220             } else {
221 4         1286 $db->insert( $self->patches_table_name() , { id => $patch->id() } );
222             }
223 9 100       2352 unless( $options->{logonly} ){
224 5         952 $self->driver()->apply_patch( $patch );
225             }else{
226 4         583 $log->info("logonly mode. Patch not really applied");
227             }
228 9         36 $db->commit();
229             };
230 9 50       149 if( my $err = $@ ){
231 0         0 $log->error("Got error $err. ROLLING BACK");
232 0         0 $db->rollback();
233 0         0 die "ERROR APPLYING PATCH ".$patch->id().": $err. ".$self->colorizer()->colored("ABORTING", "bold red")."\n";
234             };
235 9         236 $log->info($self->colorizer()->colored("Patch '".$patch->id()."' applied successfully", "green"));
236 9         31 $applied++;
237             }
238 4         77 $log->info($self->colorizer()->colored("DONE Deploying DB Patches", "green"));
239 4         35 return $applied;
240             }
241              
242             # Runs the code to return a DBIx::Simple::Result
243             # or die with the given error message (for humans)
244             #
245             # Mainly this is for testing that a table exists by attemtpting to select from
246             # it. Do NOT use that in any other cases.
247             sub _protect_select{
248 11     11   28 my ( $self, $code , $message) = @_;
249 11         14 my $result = eval{ $code->(); };
  11         22  
250 11 100 66     13052 if( my $err = $@ || $result->isa('DBIx::Simple::Dummy') ){
251 3   33     24 $log->trace("Error doing select: ".( $err || $self->dbix_simple()->error() ) );
252 3         25 die $message."\n";
253             }
254 8         21 return $result;
255             }
256              
257             sub _apply_meta_patch{
258 3     3   8 my ($self, $meta_patch) = @_;
259 3         18 $log->debug("Applying meta patch ".$meta_patch->{id});
260              
261 3         10 my $sql = $meta_patch->{sql};
262 3         66 my $db = $self->dbix_simple();
263              
264 3         13 $log->debug("Doing ".$sql);
265 3         9 eval{
266 3         13 $db->begin_work();
267 3 50       78 $db->dbh->do( $sql ) or die "Cannot do '$sql':".$db->dbh->errstr()."\n";
268 3         935 $db->insert( $self->patches_table_name() , { id => $meta_patch->{id} } );
269 3         9391 $db->commit();
270             };
271 3 50       396 if( my $err = $@ ){
272 0           $log->error("Got error $err. ROLLING BACK");
273 0           $db->rollback();
274 0           die "ERROR APPLYING META PATCH ".$meta_patch->{id}.": $err. ABORTING\n";
275             };
276             }
277              
278             __PACKAGE__->meta->make_immutable();
279             1;
280              
281             __END__
282              
283             =head1 NAME
284              
285             App::JESP - Just Enough SQL Patches
286              
287             =cut
288              
289             =head1 SYNOPSIS
290              
291             Use the command line utility:
292              
293             jesp
294              
295             Or use from your own program (in Perl):
296              
297             my $jesp = App::JESP->new({
298             interactive => 0, # No ANSI color
299             home => 'path/to/jesphome',
300             dsn => ...,
301             username => ...,
302             password => ...
303             });
304              
305             $jesp->install();
306             $jesp->deploy();
307              
308             =cut
309              
310             =head1 CONFIGURATION
311              
312             All JESP configuration must live in a JESP home directory.
313              
314             This home directory must contain a plan.json file, containing the patching
315             plan for your DB. See plan.json section below for the format of this file.
316              
317             =head2 plan.json
318              
319             This file MUST live in your JESP home directory. It has to contain
320             a json datastructure like this:
321              
322             {
323             "patches": [
324             { "id":"foobar_sql", "sql": "CREATE TABLE foobar(id INT PRIMARY KEY)"},
325             { "id":"foobar_rel", "file": "patches/morefoobar.sql" }
326             { "id":"foobar_abs", "file": "/absolute/path/to/patches/evenmore.sql" },
327             { "id":"a_backfill_script", "file": "path/to/executable/file.sh" },
328             ],
329             }
330              
331             Patches MUST have a unique C<id> in all the plan, and they can either contain raw SQL
332             (C<sql> key), or point to a C<file> (absolute, or relative to the JESP home) containing
333             the SQL.
334              
335             The C<id> is a VARCHAR(512). While it doesn't indicate any ordering, a simple and useful
336             way to keep the IDs unique is to provide a date/timestamp (of when the patch was
337             I<authored>) plus a free form description of the change.
338              
339             The L<JSON> file is parsed with the relaxed flag, which means it can contain trailing
340             commas (and # comments). The trailing commas are particularly useful, since commit diffs
341             and merge conflicts will be contained to the new line that was added.
342              
343             You are encouraged to look in L<https://github.com/jeteve/App-JESP/tree/master/t> for examples.
344              
345             =head1 PATCH TYPES
346              
347             =head1 sql
348              
349             Simply add the SQL statement to execute in your patch structure:
350              
351             {
352             "patches": [
353             ...
354             { "id":"2017-11-02: create table foobar", "sql": "CREATE TABLE foobar(id INT PRIMARY KEY)"}
355             ...
356             }
357              
358             This is convenient for very short SQL statment.
359              
360             =head1 sql files
361              
362             Point to a file that contains SQL statement(s) to be executed. The filename can be either absolute
363             or relative to the directory that contains the plan.
364              
365             {
366             "patches": [
367             ...
368             { "id":"2017-11-08: (rel) more foobar", "file": "patches/morefoobar.sql" }
369             { "id":"2017-11-12: (abs) even more", "file": "/absolute/path/to/patches/evenmore.sql" },
370             ...
371             ]
372             }
373              
374             The directory structure is free, but of course you are encouraged to keep it clean.
375              
376             =head1 script files (Unix only)
377              
378             Point to an EXECUTABLE file. (absolute or relative to the plan directory):
379              
380             {
381             "patches": [
382             ...
383             { "id":"a_backfill_script", "file": "path/to/executable/file.sh" }
384             ...
385             ]
386             }
387              
388             See L<APP::JESP::Driver#apply_script> to see what environment the script is being run in. Note that the script
389             needs to be executable by the current user to be detected as a script.
390              
391             =head1 COMPATIBILITY
392              
393             Compatibility of the meta-schema with SQLite, MySQL and PostgreSQL is guaranteed through automated testing.
394             To see which versions are actually tested, look at the CI build:
395             L<https://travis-ci.org/jeteve/App-JESP/>
396              
397             =head1 DRIVERS
398              
399             This comes with the following built-in drivers:
400              
401             =head2 SQLite
402              
403             Just in case. Note that your patches will be executed in the same connection
404             this uses to manage the metadata.
405              
406             =head2 mysql
407              
408             This will use the `mysql` executable on the disk (will look for it in PATH)
409             to execute your patches, exactly like you would do on the command line.
410              
411             =head2 Pg
412              
413             This will use a new connection to the Database to execute the patches.
414             This is to allow you using BEGIN ; COMMIT; to make your patch transactional
415             without colliding with the Meta data management transaction.
416              
417             =head2 Your own driver
418              
419             Should you want to write your own driver, simply extend L<App::JESP::Driver>
420             and implement any method you like (most likely you will want apply_sql).
421              
422             To use your driver, simply give its class to the constuctor:
423              
424             my $jesp = App::JESP->new({ .., driver_class => 'My::App::JESP::Driver::SpecialDB' });
425              
426             Or if you prefer to build an instance yourself:
427              
428             my $jesp;
429             $jesp = App::JESP->new({ .., driver => My::App::JESP::Driver::SpecialDB->new({ jesp => $jesp, ... ) });
430              
431             =head1 MOTIVATIONS & DESIGN
432              
433             Over the years as a developer, I have used at least three ways of managing SQL patches.
434             The ad-hoc way with a hand-rolled system which is painful to re-implement,
435             the L<DBIx::Class::Migration> way which I didn't like at all, and more recently
436             L<App::Sqitch> which I sort of like.
437              
438             All these systems somehow just manage to do the job, but unless they are very complicated (there
439             are no limits to hand-rolled complications..) they all fail to provide a sensible
440             way for a team of developers to work on database schema changes at the same time.
441              
442             So I decided the world needs yet another SQL patch management system that
443             does what my team and I really really want.
444              
445             Here are some design principles this package is attempting to implement:
446              
447             =over
448              
449             =item Write your own SQL
450              
451             No funny SQL generated from code here. By nature, any ORM will always lag behind its
452             target DBs' features. This means that counting on software to generate SQL statement from
453             your ORM classes will always prevent you from truly using the full power of your DB of choice.
454              
455             With App::JESP, you have to write your own SQL for your DB, and this is a good thing.
456              
457             =item No version numbers
458              
459             App::JESP simply keeps track of which ones of your named patches are applied to the DB.
460             Your DB version is just that: The subset of patches that were applied to it. This participates
461             in allowing several developers to work on different parts of the DB in parallel.
462              
463             =item No fuss patch ordering
464              
465             The order in which patches are applied is important. But it is not important
466             to the point of enforcing exactly the same order on every DB the patches are deployed to.
467             App::JESP applies the named patches in the order it finds them in the plan, only taking
468             into account the ones that have not been applied yet. This allows developer to work
469             on their development DB and easily merge patches from other developers.
470              
471             =item JSON Based
472              
473             This is the 21st century, and I feel like I shouldn't invent my own file format.
474             This uses JSON like everything else.
475              
476             =item Simple but complex things allowed.
477              
478             You will find no complex feature in App::JESP, and we pledge to keep the meta schema
479             simple, to allow for easy repairs if things go wrong.
480              
481             =item Programmable
482              
483             It's great to have a convenient command line tool to work and deploy patches, but maybe
484             your development process, or your code layout is a bit different. If you use L<App::JESP>
485             from Perl, it should be easy to embed and run it yourself.
486              
487             =item What about reverting?
488              
489             Your live DB is not the place to test your changes. Your DB at <My Software> Version N should
490             be compatible with Code at <My Software> Version N-1. You are responsible for testing that.
491              
492             We'll probably implement reverting in the future, but for now we assume you
493             know what you're doing when you patch your DB.
494              
495             =back
496              
497             =head1 METHODS
498              
499             =head2 install
500              
501             Installs or upgrades the JESP meta tables in the database. This is idempotent.
502             Note that the JESP meta table(s) will be all prefixed by B<$this->prefix()>.
503              
504             Returns true on success. Will die on error.
505              
506             Usage:
507              
508             $this->install();
509              
510             =head2 status
511              
512             Returns the list of L<App::JESP::Patch> with their application Datetime if this is known.
513             This will ALSO return the orphans (patches that are recorded as applied but not in the plan).
514              
515             Usage:
516              
517             my $status = $this->status();
518              
519             $status is like:
520             {
521             plan_patches => [ list of App::JESP::Patch ],
522             plan_orphans => [ list of orphan patch IDs ]
523             };
524              
525             =head2 deploy
526              
527             Deploys the unapplied patches from the plan in the database and record
528             the new DB state in the meta schema. Dies if the meta schema is not installed (see install method).
529              
530             Returns the number of patches applied.
531              
532             Usage:
533              
534             print "Applied ".$this->deploy()." patches";
535              
536             Options:
537              
538             =over
539              
540             =item patches [ 'patch_one' , 'patch_two' ]
541              
542             Specify the patches to apply. This is useful in combination with C<force>
543             (to force a data producing patch to run for instance), or with C<logonly>.
544              
545             =item force 1|0
546              
547             Force patches applications, regardless of the fact they have been applied already or not.
548             Note that it does not mean it's ok for the patches to fail. Any failing patch will still
549             terminate the deploy method. This is particularly useful in combination with the 'patches'
550             option where you can choose which patch to apply. Defaults to 0.
551              
552             =item logonly 1|0
553              
554             Only record the application of patches in the metadata, without effectively applying them.
555              
556             =back
557              
558              
559             =head1 DEVELOPMENT
560              
561             =for html <a href="https://travis-ci.org/jeteve/App-JESP"><img src="https://travis-ci.org/jeteve/App-JESP.svg?branch=master"></a>
562              
563             =head1 COPYRIGHT
564              
565             This software is released under the Artistic Licence by Jerome Eteve. Copyright 2016.
566             A copy of this licence is enclosed in this package.
567              
568             =cut