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