fixed ::Versioned class
[dbsrgits/DBIx-Class-Fixtures.git] / lib / DBIx / Class / Fixtures.pm
1 package DBIx::Class::Fixtures;
2
3 use strict;
4 use warnings;
5
6 use DBIx::Class::Exception;
7 use Class::Accessor;
8 use Path::Class qw(dir file);
9 use Config::Any::JSON;
10 use Data::Dump::Streamer;
11 use Data::Visitor::Callback;
12 use File::Slurp;
13 use File::Path;
14 use File::Copy::Recursive qw/dircopy/;
15 use Hash::Merge qw( merge );
16 use Data::Dumper;
17
18 use base qw(Class::Accessor);
19
20 __PACKAGE__->mk_accessors(qw(config_dir _inherited_attributes debug schema_class ));
21
22 =head1 VERSION
23
24 Version 1.000
25
26 =cut
27
28 our $VERSION = '1.000';
29
30 =head1 NAME
31
32 DBIx::Class::Fixtures
33
34 =head1 SYNOPSIS
35
36   use DBIx::Class::Fixtures;
37
38   ...
39
40   my $fixtures = DBIx::Class::Fixtures->new({ config_dir => '/home/me/app/fixture_configs' });
41
42   $fixtures->dump({
43     config => 'set_config.json',
44     schema => $source_dbic_schema,
45     directory => '/home/me/app/fixtures'
46   });
47
48   $fixtures->populate({
49     directory => '/home/me/app/fixtures',
50     ddl => '/home/me/app/sql/ddl.sql',
51     connection_details => ['dbi:mysql:dbname=app_dev', 'me', 'password']
52   });
53
54 =head1 DESCRIPTION
55
56 =head1 AUTHOR
57
58 =head1 CONTRIBUTORS
59
60 =head1 METHODS
61
62 =head2 new
63
64 =cut
65
66 sub new {
67   my $class = shift;
68
69   my ($params) = @_;
70   unless (ref $params eq 'HASH') {
71     return DBIx::Class::Exception->throw('first arg to DBIx::Class::Fixtures->new() must be hash ref');
72   }
73
74   unless ($params->{config_dir}) {
75     return DBIx::Class::Exception->throw('config_dir param not specified');
76   }
77
78   my $config_dir = dir($params->{config_dir});
79   unless (-e $params->{config_dir}) {
80     return DBIx::Class::Exception->throw('config_dir directory doesn\'t exist');
81   }
82
83   my $self = {
84               config_dir => $config_dir,
85               _inherited_attributes => [qw/datetime_relative might_have rules/],
86               debug => $params->{debug}
87   };
88
89   bless $self, $class;
90
91   return $self;
92 }
93
94 =head2 dump
95
96 =cut
97
98 sub dump {
99   my $self = shift;
100
101   my ($params) = @_;
102   unless (ref $params eq 'HASH') {
103     return DBIx::Class::Exception->throw('first arg to dump must be hash ref');
104   }
105
106   foreach my $param (qw/config schema directory/) {
107     unless ($params->{$param}) {
108       return DBIx::Class::Exception->throw($param . ' param not specified');
109     }
110   }
111
112   my $config_file = file($self->config_dir, $params->{config});
113   unless (-e $config_file) {
114     return DBIx::Class::Exception->throw('config does not exist at ' . $config_file);
115   }
116
117   my $config = Config::Any::JSON->load($config_file);
118   unless ($config && $config->{sets} && ref $config->{sets} eq 'ARRAY' && scalar(@{$config->{sets}})) {
119     return DBIx::Class::Exception->throw('config has no sets');
120   }
121
122   my $output_dir = dir($params->{directory});
123   unless (-e $output_dir) {
124     return DBIx::Class::Exception->throw('output directory does not exist at ' . $output_dir);
125   }
126
127   my $schema = $params->{schema};
128
129   $self->msg("generating  fixtures");
130   my $tmp_output_dir = dir($output_dir, '-~dump~-');
131
132   unless (-e $tmp_output_dir) {
133     $self->msg("- creating $tmp_output_dir");
134     mkdir($tmp_output_dir, 0777);
135   }else {
136     $self->msg("- clearing existing $tmp_output_dir");
137     # delete existing fixture set
138     system("rm -rf $tmp_output_dir/*");
139   }
140
141   # write version file (for the potential benefit of populate)
142   my $version_file = file($tmp_output_dir, '_dumper_version');
143   write_file($version_file->stringify, $VERSION);
144
145   $config->{rules} ||= {};
146   my @sources = sort { $a->{class} cmp $b->{class} } @{delete $config->{sets}};
147   my %options = ( is_root => 1 );
148   foreach my $source (@sources) {
149     # apply rule to set if specified
150     my $rule = $config->{rules}->{$source->{class}};
151     $source = merge( $source, $rule ) if ($rule);
152
153     # fetch objects
154     my $rs = $schema->resultset($source->{class});      
155         $rs = $rs->search($source->{cond}, { join => $source->{join} }) if ($source->{cond});
156     $self->msg("- dumping $source->{class}");
157     my @objects;
158     my %source_options = ( set => { %{$config}, %{$source} } );
159     if ($source->{quantity}) {
160       $rs = $rs->search({}, { order_by => $source->{order_by} }) if ($source->{order_by});
161       if ($source->{quantity} eq 'all') {
162         push (@objects, $rs->all);
163       } elsif ($source->{quantity} =~ /^\d+$/) {
164         push (@objects, $rs->search({}, { rows => $source->{quantity} }));
165       } else {
166         DBIx::Class::Exception->throw('invalid value for quantity - ' . $source->{quantity});
167       }
168     }
169     if ($source->{ids}) {
170       my @ids = @{$source->{ids}};
171       my @id_objects = grep { $_ } map { $rs->find($_) } @ids;
172       push (@objects, @id_objects);
173     }
174     unless ($source->{quantity} || $source->{ids}) {
175       DBIx::Class::Exception->throw('must specify either quantity or ids');
176     }
177
178     # dump objects
179     foreach my $object (@objects) {
180       $source_options{set_dir} = $tmp_output_dir;
181       $self->dump_object($object, { %options, %source_options } );
182       next;
183     }
184   }
185
186   foreach my $dir ($output_dir->children) {
187     next if ($dir eq $tmp_output_dir);
188     $dir->remove || $dir->rmtree;
189   }
190
191   $self->msg("- moving temp dir to $output_dir");
192   system("mv $tmp_output_dir/* $output_dir/");
193   if (-e $output_dir) {
194     $self->msg("- clearing tmp dir $tmp_output_dir");
195     # delete existing fixture set
196     $tmp_output_dir->remove;
197   }
198
199   $self->msg("done");
200
201   return 1;
202 }
203
204 sub dump_object {
205   my ($self, $object, $params, $rr_info) = @_;  
206   my $set = $params->{set};
207   die 'no dir passed to dump_object' unless $params->{set_dir};
208   die 'no object passed to dump_object' unless $object;
209
210   my @inherited_attrs = @{$self->_inherited_attributes};
211
212   # write dir and gen filename
213   my $source_dir = dir($params->{set_dir}, lc($object->result_source->from));
214   mkdir($source_dir->stringify, 0777);
215   my $file = file($source_dir, join('-', map { $object->get_column($_) } sort $object->primary_columns) . '.fix');
216
217   # write file
218   my $exists = (-e $file->stringify) ? 1 : 0;
219   unless ($exists) {
220     $self->msg('-- dumping ' . $file->stringify, 2);
221     my %ds = $object->get_columns;
222
223     # mess with dates if specified
224     if ($set->{datetime_relative}) {     
225       my $dt;
226       if ($set->{datetime_relative} eq 'today') {
227         $dt = DateTime->today;
228       } else {
229         require DateTime::Format::MySQL;
230         $dt = DateTime::Format::MySQL->parse_datetime($set->{datetime_relative});
231       }
232
233       while (my ($col, $value) = each %ds) {
234         my $col_info = $object->result_source->column_info($col);
235
236         next unless $value
237           && $col_info->{_inflate_info}
238             && uc($col_info->{data_type}) eq 'DATETIME';
239
240         $ds{$col} = $object->get_inflated_column($col)->subtract_datetime($dt);
241       }
242     }
243
244     # do the actual dumping
245     my $serialized = Dump(\%ds)->Out();
246     write_file($file->stringify, $serialized);
247     my $mode = 0777; chmod $mode, $file->stringify;  
248   }
249
250   # dump rels of object
251   my $s = $object->result_source;
252   unless ($exists) {
253     foreach my $name (sort $s->relationships) {
254       my $info = $s->relationship_info($name);
255       my $r_source = $s->related_source($name);
256       # if belongs_to or might_have with might_have param set or has_many with has_many param set then
257       if (($info->{attrs}{accessor} eq 'single' && (!$info->{attrs}{join_type} || ($set->{might_have} && $set->{might_have}->{fetch}))) || $info->{attrs}{accessor} eq 'filter' || ($info->{attrs}{accessor} eq 'multi' && ($set->{has_many} && $set->{has_many}->{fetch}))) {
258         my $related_rs = $object->related_resultset($name);       
259         my $rule = $set->{rules}->{$related_rs->result_source->source_name};
260         # these parts of the rule only apply to has_many rels
261         if ($rule && $info->{attrs}{accessor} eq 'multi') {               
262           $related_rs = $related_rs->search($rule->{cond}, { join => $rule->{join} }) if ($rule->{cond});
263           $related_rs = $related_rs->search({}, { rows => $rule->{quantity} }) if ($rule->{quantity} && $rule->{quantity} ne 'all');
264           $related_rs = $related_rs->search({}, { order_by => $rule->{order_by} }) if ($rule->{order_by});                
265         }
266         if ($set->{has_many}->{quantity} && $set->{has_many}->{quantity} =~ /^\d+$/) {
267           $related_rs = $related_rs->search({}, { rows => $set->{has_many}->{quantity} });
268         }
269         my %c_params = %{$params};
270         # inherit date param
271         my %mock_set = map { $_ => $set->{$_} } grep { $set->{$_} } @inherited_attrs;
272         $c_params{set} = \%mock_set;
273         #               use Data::Dumper; print ' -- ' . Dumper($c_params{set}, $rule->{fetch}) if ($rule && $rule->{fetch});
274         $c_params{set} = merge( $c_params{set}, $rule) if ($rule && $rule->{fetch});
275         #               use Data::Dumper; print ' -- ' . Dumper(\%c_params) if ($rule && $rule->{fetch});
276         $self->dump_object($_, \%c_params) foreach $related_rs->all;      
277       } 
278     }
279   }
280   
281   return unless $set && $set->{fetch};
282   foreach my $fetch (@{$set->{fetch}}) {
283     # inherit date param
284     $fetch->{$_} = $set->{$_} foreach grep { !$fetch->{$_} && $set->{$_} } @inherited_attrs;
285     my $related_rs = $object->related_resultset($fetch->{rel});
286     my $rule = $set->{rules}->{$related_rs->result_source->source_name};
287     if ($rule) {
288       my $info = $object->result_source->relationship_info($fetch->{rel});
289       if ($info->{attrs}{accessor} eq 'multi') {
290         $fetch = merge( $fetch, $rule );
291       } elsif ($rule->{fetch}) {
292         $fetch = merge( $fetch, { fetch => $rule->{fetch} } );
293       }
294     } 
295     die "relationship " . $fetch->{rel} . " does not exist for " . $s->source_name unless ($related_rs);
296     if ($fetch->{cond} and ref $fetch->{cond} eq 'HASH') {
297       # if value starts with / assume it's meant to be passed as a scalar ref to dbic
298       # ideally this would substitute deeply
299       $fetch->{cond} = { map { $_ => ($fetch->{cond}->{$_} =~ s/^\\//) ? \$fetch->{cond}->{$_} : $fetch->{cond}->{$_} } keys %{$fetch->{cond}} };
300     }
301     $related_rs = $related_rs->search($fetch->{cond}, { join => $fetch->{join} }) if ($fetch->{cond});
302     $related_rs = $related_rs->search({}, { rows => $fetch->{quantity} }) if ($fetch->{quantity} && $fetch->{quantity} ne 'all');
303     $related_rs = $related_rs->search({}, { order_by => $fetch->{order_by} }) if ($fetch->{order_by});
304     $self->dump_object($_, { %{$params}, set => $fetch }) foreach $related_rs->all;
305   }
306 }
307
308 sub _generate_schema {
309   my $self = shift;
310   my $params = shift || {};
311   require DBI;
312   $self->msg("\ncreating schema");
313   #   die 'must pass version param to generate_schema_from_ddl' unless $params->{version};
314
315   my $schema_class = $self->schema_class || "DBIx::Class::Fixtures::Schema";
316   eval "require $schema_class";
317   die $@ if $@;
318
319   my $pre_schema;
320   my $connection_details = $params->{connection_details};
321   unless( $pre_schema = $schema_class->connect(@{$connection_details}) ) {
322     return DBIx::Class::Exception->throw('connection details not valid');
323   }
324   my @tables = map { $pre_schema->source($_)->from }$pre_schema->sources;
325   my $dbh = $pre_schema->storage->dbh;
326
327   # clear existing db
328   $self->msg("- clearing DB of existing tables");
329   eval { $dbh->do('SET foreign_key_checks=0') };
330   $dbh->do('drop table ' . $_) for (@tables);
331
332   # import new ddl file to db
333   my $ddl_file = $params->{ddl};
334   $self->msg("- deploying schema using $ddl_file");
335   my $fh;
336   open $fh, "<$ddl_file" or die ("Can't open DDL file, $ddl_file ($!)");
337   my @data = split(/\n/, join('', <$fh>));
338   @data = grep(!/^--/, @data);
339   @data = split(/;/, join('', @data));
340   close($fh);
341   @data = grep { $_ && $_ !~ /^-- / } @data;
342   for (@data) {
343       eval { $dbh->do($_) or warn "SQL was:\n $_"};
344           if ($@) { die "SQL was:\n $_\n$@"; }
345   }
346   $self->msg("- finished importing DDL into DB");
347
348   # load schema object from our new DB
349   $self->msg("- loading fresh DBIC object from DB");
350   my $schema = $schema_class->connect(@{$connection_details});
351   return $schema;
352 }
353
354 sub populate {
355   my $self = shift;
356   my ($params) = @_;
357   unless (ref $params eq 'HASH') {
358     return DBIx::Class::Exception->throw('first arg to populate must be hash ref');
359   }
360
361   foreach my $param (qw/directory/) {
362     unless ($params->{$param}) {
363       return DBIx::Class::Exception->throw($param . ' param not specified');
364     }
365   }
366   my $fixture_dir = dir(delete $params->{directory});
367   unless (-e $fixture_dir) {
368     return DBIx::Class::Exception->throw('fixture directory does not exist at ' . $fixture_dir);
369   }
370
371   my $ddl_file;
372   my $dbh;  
373   if ($params->{ddl} && $params->{connection_details}) {
374     $ddl_file = file(delete $params->{ddl});
375     unless (-e $ddl_file) {
376       return DBIx::Class::Exception->throw('DDL does not exist at ' . $ddl_file);
377     }
378     unless (ref $params->{connection_details} eq 'ARRAY') {
379       return DBIx::Class::Exception->throw('connection details must be an arrayref');
380     }
381   } elsif ($params->{schema}) {
382     return DBIx::Class::Exception->throw('passing a schema is not supported at the moment');
383   } else {
384     return DBIx::Class::Exception->throw('you must set the ddl and connection_details params');
385   }
386
387   my $schema = $self->_generate_schema({ ddl => $ddl_file, connection_details => delete $params->{connection_details}, %{$params} });
388   $self->msg("\nimporting fixtures");
389   my $tmp_fixture_dir = dir($fixture_dir, "-~populate~-" . $<);
390
391   my $version_file = file($fixture_dir, '_dumper_version');
392   unless (-e $version_file) {
393 #     return DBIx::Class::Exception->throw('no version file found');
394   }
395
396   if (-e $tmp_fixture_dir) {
397     $self->msg("- deleting existing temp directory $tmp_fixture_dir");
398     $tmp_fixture_dir->rmtree;
399   }
400   $self->msg("- creating temp dir");
401   dircopy(dir($fixture_dir, $schema->source($_)->from), dir($tmp_fixture_dir, $schema->source($_)->from)) for $schema->sources;
402
403   eval { $schema->storage->dbh->do('SET foreign_key_checks=0') };
404   my $fixup_visitor;
405   my %callbacks;
406   if ($params->{datetime_relative_to}) {
407     $callbacks{'DateTime::Duration'} = sub {
408       $params->{datetime_relative_to}->clone->add_duration($_);
409     };
410   } else {
411     $callbacks{'DateTime::Duration'} = sub {
412       DateTime->today->add_duration($_)
413     };
414   }
415   $callbacks{object} ||= "visit_ref";   
416   $fixup_visitor = new Data::Visitor::Callback(%callbacks);
417
418   foreach my $source (sort $schema->sources) {
419     $self->msg("- adding " . $source);
420     my $rs = $schema->resultset($source);
421     my $source_dir = dir($tmp_fixture_dir, lc($rs->result_source->from));
422     next unless (-e $source_dir);
423     while (my $file = $source_dir->next) {
424       next unless ($file =~ /\.fix$/);
425       next if $file->is_dir;
426       my $contents = $file->slurp;
427       my $HASH1;
428       eval($contents);
429       $HASH1 = $fixup_visitor->visit($HASH1) if $fixup_visitor;
430       $rs->find_or_create($HASH1);
431     }
432   }
433
434   $self->msg("- fixtures imported");
435   $self->msg("- cleaning up");
436   $tmp_fixture_dir->rmtree;
437   eval { $schema->storage->dbh->do('SET foreign_key_checks=1') };
438 }
439
440 sub msg {
441   my $self = shift;
442   my $subject = shift || return;
443   my $level = shift || 1;
444
445   return unless $self->debug >= $level;
446   if (ref $subject) {
447         print Dumper($subject);
448   } else {
449         print $subject . "\n";
450   }
451 }
452 1;