File Coverage

blib/lib/App/JESP.pm
Criterion Covered Total %
statement 158 167 94.6
branch 29 34 85.2
condition 5 9 55.5
subroutine 28 28 100.0
pod 3 3 100.0
total 223 241 92.5


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