1 package DBIx::Class::Fixtures;
6 use DBIx::Class::Exception;
7 use Class::Accessor::Grouped;
8 use Path::Class qw(dir file);
10 use Config::Any::JSON;
11 use Data::Dump::Streamer;
12 use Data::Visitor::Callback;
14 use File::Copy::Recursive qw/dircopy/;
15 use File::Copy qw/move/;
16 use Hash::Merge qw( merge );
19 use base qw(Class::Accessor::Grouped);
22 'mysql' => 'DateTime::Format::MySQL',
23 'pg' => 'DateTime::Format::Pg',
26 __PACKAGE__->mk_group_accessors( 'simple' => qw/config_dir _inherited_attributes debug schema_class/);
34 our $VERSION = '1.000';
42 use DBIx::Class::Fixtures;
46 my $fixtures = DBIx::Class::Fixtures->new({ config_dir => '/home/me/app/fixture_configs' });
49 config => 'set_config.json',
50 schema => $source_dbic_schema,
51 directory => '/home/me/app/fixtures'
55 directory => '/home/me/app/fixtures',
56 ddl => '/home/me/app/sql/ddl.sql',
57 connection_details => ['dbi:mysql:dbname=app_dev', 'me', 'password']
62 Dump fixtures from source database to filesystem then import to another database (with same schema)
63 at any time. Use as a constant dataset for running tests against or for populating development databases
64 when impractical to use production clones. Describe fixture set using relations and conditions based
65 on your DBIx::Class schema.
67 =head1 DEFINE YOUR FIXTURE SET
69 Fixture sets are currently defined in .json files which must reside in your config_dir
70 (e.g. /home/me/app/fixture_configs/a_fixture_set.json). They describe which data to pull and dump
71 from the source database.
89 This will fetch artists with primary keys 1 and 3, the producer with primary key 5 and two of producer 5's
90 artists where 'artists' is a has_many DBIx::Class rel from Producer to Artist.
92 The top level attributes are as follows:
96 Sets must be an array of hashes, as in the example given above. Each set defines a set of objects to be
97 included in the fixtures. For details on valid set attributes see L</SET ATTRIBUTES> below.
101 Rules place general conditions on classes. For example if whenever an artist was dumped you also wanted all
102 of their cds dumped too, then you could use a rule to specify this. For example:
126 In this case all the cds of artists 1, 3 and all producer 5's artists will be dumped as well. Note that 'cds' is a
127 has_many DBIx::Class relation from Artist to CD. This is eqivalent to:
151 rules must be a hash keyed by class name.
155 =head2 datetime_relative
157 Only available for MySQL and PostgreSQL at the moment, must be a value that DateTime::Format::*
158 can parse. For example:
162 class: 'RecentItems',
165 datetime_relative : "2007-10-30 00:00:00"
168 This will work when dumping from a MySQL database and will cause any datetime fields (where datatype => 'datetime'
169 in the column def of the schema class) to be dumped as a DateTime::Duration object relative to the date specified in
170 the datetime_relative value. For example if the RecentItem object had a date field set to 2007-10-25, then when the
171 fixture is imported the field will be set to 5 days in the past relative to the current time.
175 Specifies whether to automatically dump might_have relationships. Should be a hash with one attribute - fetch. Set fetch to 1 or 0.
190 Note: belongs_to rels are automatically dumped whether you like it or not, this is to avoid FKs to nowhere when importing.
191 General rules on has_many rels are not accepted at this top level, but you can turn them on for individual
192 sets - see L</SET ATTRIBUTES>.
194 =head1 SET ATTRIBUTES
198 Required attribute. Specifies the DBIx::Class object class you wish to dump.
202 Array of primary key ids to fetch, basically causing an $rs->find($_) for each. If the id is not in the source db then it
203 just won't get dumped, no warnings or death.
207 Must be either an integer or the string 'all'. Specifying an integer will effectively set the 'rows' attribute on the resultset clause,
208 specifying 'all' will cause the rows attribute to be left off and for all matching rows to be dumped. There's no randomising
209 here, it's just the first x rows.
213 A hash specifying the conditions dumped objects must match. Essentially this is a JSON representation of a DBIx::Class search clause. For example:
219 cond: { name: 'Dave' }
223 This will dump all artists whose name is 'dave'. Essentially $artist_rs->search({ name => 'Dave' })->all.
225 Sometimes in a search clause it's useful to use scalar refs to do things like:
227 $artist_rs->search({ no1_singles => \'> no1_albums' })
229 This could be specified in the cond hash like so:
235 cond: { no1_singles: '\> no1_albums' }
239 So if the value starts with a backslash the value is made a scalar ref before being passed to search.
243 An array of relationships to be used in the cond clause.
249 cond: { 'cds.position': { '>': 4 } },
254 Fetch all artists who have cds with position greater than 4.
258 Must be an array of hashes. Specifies which rels to also dump. For example:
267 cond: { position: '2' }
272 Will cause the cds of artists 1 and 3 to be dumped where the cd position is 2.
274 Valid attributes are: 'rel', 'quantity', 'cond', 'has_many', 'might_have' and 'join'. rel is the name of the DBIx::Class
275 rel to follow, the rest are the same as in the set attributes. quantity is necessary for has_many relationships,
276 but not if using for belongs_to or might_have relationships.
280 Specifies whether to fetch has_many rels for this set. Must be a hash containing keys fetch and quantity.
282 Set fetch to 1 if you want to fetch them, and quantity to either 'all' or an integer.
284 Be careful here, dumping has_many rels can lead to a lot of data being dumped.
288 As with has_many but for might_have relationships. Quantity doesn't do anything in this case.
290 This value will be inherited by all fetches in this set. This is not true for the has_many attribute.
292 =head1 RULE ATTRIBUTES
296 Same as with L</SET ATTRIBUTES>
300 Same as with L</SET ATTRIBUTES>
304 Same as with L</SET ATTRIBUTES>
308 Same as with L</SET ATTRIBUTES>
312 Same as with L</SET ATTRIBUTES>
320 =item Arguments: \%$attrs
322 =item Return Value: $fixture_object
326 Returns a new DBIx::Class::Fixture object. %attrs has only two valid keys at the
327 moment - 'debug' which determines whether to be verbose and 'config_dir' which is required and much contain a valid path to
328 the directory in which your .json configs reside.
330 my $fixtures = DBIx::Class::Fixtures->new({ config_dir => '/home/me/app/fixture_configs' });
338 unless (ref $params eq 'HASH') {
339 return DBIx::Class::Exception->throw('first arg to DBIx::Class::Fixtures->new() must be hash ref');
342 unless ($params->{config_dir}) {
343 return DBIx::Class::Exception->throw('config_dir param not specified');
346 my $config_dir = dir($params->{config_dir});
347 unless (-e $params->{config_dir}) {
348 return DBIx::Class::Exception->throw('config_dir directory doesn\'t exist');
352 config_dir => $config_dir,
353 _inherited_attributes => [qw/datetime_relative might_have rules/],
354 debug => $params->{debug}
366 =item Arguments: \%$attrs
368 =item Return Value: 1
373 config => 'set_config.json', # config file to use. must be in the config directory specified in the constructor
374 schema => $source_dbic_schema,
375 directory => '/home/me/app/fixtures' # output directory
378 In this case objects will be dumped to subdirectories in the specified directory. For example:
380 /home/me/app/fixtures/artist/1.fix
381 /home/me/app/fixtures/artist/3.fix
382 /home/me/app/fixtures/producer/5.fix
384 config, schema and directory are all required attributes.
392 unless (ref $params eq 'HASH') {
393 return DBIx::Class::Exception->throw('first arg to dump must be hash ref');
396 foreach my $param (qw/config schema directory/) {
397 unless ($params->{$param}) {
398 return DBIx::Class::Exception->throw($param . ' param not specified');
402 my $config_file = file($self->config_dir, $params->{config});
403 unless (-e $config_file) {
404 return DBIx::Class::Exception->throw('config does not exist at ' . $config_file);
407 my $config = Config::Any::JSON->load($config_file);
408 unless ($config && $config->{sets} && ref $config->{sets} eq 'ARRAY' && scalar(@{$config->{sets}})) {
409 return DBIx::Class::Exception->throw('config has no sets');
412 my $output_dir = dir($params->{directory});
413 unless (-e $output_dir) {
414 return DBIx::Class::Exception->throw('output directory does not exist at ' . $output_dir);
417 my $schema = $params->{schema};
419 $self->msg("generating fixtures");
420 my $tmp_output_dir = dir($output_dir, '-~dump~-');
422 if (-e $tmp_output_dir) {
423 $self->msg("- clearing existing $tmp_output_dir");
424 $tmp_output_dir->rmtree;
426 $self->msg("- creating $tmp_output_dir");
427 $tmp_output_dir->mkpath;
429 # write version file (for the potential benefit of populate)
430 my $version_file = file($tmp_output_dir, '_dumper_version');
431 write_file($version_file->stringify, $VERSION);
433 $config->{rules} ||= {};
434 my @sources = sort { $a->{class} cmp $b->{class} } @{delete $config->{sets}};
435 my %options = ( is_root => 1 );
436 foreach my $source (@sources) {
437 # apply rule to set if specified
438 my $rule = $config->{rules}->{$source->{class}};
439 $source = merge( $source, $rule ) if ($rule);
442 my $rs = $schema->resultset($source->{class});
443 $rs = $rs->search($source->{cond}, { join => $source->{join} }) if ($source->{cond});
444 $self->msg("- dumping $source->{class}");
446 my %source_options = ( set => { %{$config}, %{$source} } );
447 if ($source->{quantity}) {
448 $rs = $rs->search({}, { order_by => $source->{order_by} }) if ($source->{order_by});
449 if ($source->{quantity} eq 'all') {
450 push (@objects, $rs->all);
451 } elsif ($source->{quantity} =~ /^\d+$/) {
452 push (@objects, $rs->search({}, { rows => $source->{quantity} }));
454 DBIx::Class::Exception->throw('invalid value for quantity - ' . $source->{quantity});
457 if ($source->{ids}) {
458 my @ids = @{$source->{ids}};
459 my @id_objects = grep { $_ } map { $rs->find($_) } @ids;
460 push (@objects, @id_objects);
462 unless ($source->{quantity} || $source->{ids}) {
463 DBIx::Class::Exception->throw('must specify either quantity or ids');
467 foreach my $object (@objects) {
468 $source_options{set_dir} = $tmp_output_dir;
469 $self->dump_object($object, { %options, %source_options } );
474 foreach my $dir ($output_dir->children) {
475 next if ($dir eq $tmp_output_dir);
476 $dir->remove || $dir->rmtree;
479 $self->msg("- moving temp dir to $output_dir");
480 move($_, dir($output_dir, $_->relative($_->parent)->stringify)) for $tmp_output_dir->children;
481 if (-e $output_dir) {
482 $self->msg("- clearing tmp dir $tmp_output_dir");
483 # delete existing fixture set
484 $tmp_output_dir->remove;
493 my ($self, $object, $params, $rr_info) = @_;
494 my $set = $params->{set};
495 die 'no dir passed to dump_object' unless $params->{set_dir};
496 die 'no object passed to dump_object' unless $object;
498 my @inherited_attrs = @{$self->_inherited_attributes};
500 # write dir and gen filename
501 my $source_dir = dir($params->{set_dir}, lc($object->result_source->from));
502 mkdir($source_dir->stringify, 0777);
503 my $file = file($source_dir, join('-', map { $object->get_column($_) } sort $object->primary_columns) . '.fix');
506 my $exists = (-e $file->stringify) ? 1 : 0;
508 $self->msg('-- dumping ' . $file->stringify, 2);
509 my %ds = $object->get_columns;
511 my $formatter= $object->result_source->schema->storage->datetime_parser;
512 # mess with dates if specified
513 if ($set->{datetime_relative}) {
514 unless ($@ || !$formatter) {
516 if ($set->{datetime_relative} eq 'today') {
517 $dt = DateTime->today;
519 $dt = $formatter->parse_datetime($set->{datetime_relative}) unless ($@);
522 while (my ($col, $value) = each %ds) {
523 my $col_info = $object->result_source->column_info($col);
526 && $col_info->{_inflate_info}
527 && uc($col_info->{data_type}) eq 'DATETIME';
529 $ds{$col} = $object->get_inflated_column($col)->subtract_datetime($dt);
532 warn "datetime_relative not supported for this db driver at the moment";
536 # do the actual dumping
537 my $serialized = Dump(\%ds)->Out();
538 write_file($file->stringify, $serialized);
539 my $mode = 0777; chmod $mode, $file->stringify;
542 # dump rels of object
543 my $s = $object->result_source;
545 foreach my $name (sort $s->relationships) {
546 my $info = $s->relationship_info($name);
547 my $r_source = $s->related_source($name);
548 # if belongs_to or might_have with might_have param set or has_many with has_many param set then
549 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}))) {
550 my $related_rs = $object->related_resultset($name);
551 my $rule = $set->{rules}->{$related_rs->result_source->source_name};
552 # these parts of the rule only apply to has_many rels
553 if ($rule && $info->{attrs}{accessor} eq 'multi') {
554 $related_rs = $related_rs->search($rule->{cond}, { join => $rule->{join} }) if ($rule->{cond});
555 $related_rs = $related_rs->search({}, { rows => $rule->{quantity} }) if ($rule->{quantity} && $rule->{quantity} ne 'all');
556 $related_rs = $related_rs->search({}, { order_by => $rule->{order_by} }) if ($rule->{order_by});
558 if ($set->{has_many}->{quantity} && $set->{has_many}->{quantity} =~ /^\d+$/) {
559 $related_rs = $related_rs->search({}, { rows => $set->{has_many}->{quantity} });
561 my %c_params = %{$params};
563 my %mock_set = map { $_ => $set->{$_} } grep { $set->{$_} } @inherited_attrs;
564 $c_params{set} = \%mock_set;
565 # use Data::Dumper; print ' -- ' . Dumper($c_params{set}, $rule->{fetch}) if ($rule && $rule->{fetch});
566 $c_params{set} = merge( $c_params{set}, $rule) if ($rule && $rule->{fetch});
567 # use Data::Dumper; print ' -- ' . Dumper(\%c_params) if ($rule && $rule->{fetch});
568 $self->dump_object($_, \%c_params) foreach $related_rs->all;
573 return unless $set && $set->{fetch};
574 foreach my $fetch (@{$set->{fetch}}) {
576 $fetch->{$_} = $set->{$_} foreach grep { !$fetch->{$_} && $set->{$_} } @inherited_attrs;
577 my $related_rs = $object->related_resultset($fetch->{rel});
578 my $rule = $set->{rules}->{$related_rs->result_source->source_name};
580 my $info = $object->result_source->relationship_info($fetch->{rel});
581 if ($info->{attrs}{accessor} eq 'multi') {
582 $fetch = merge( $fetch, $rule );
583 } elsif ($rule->{fetch}) {
584 $fetch = merge( $fetch, { fetch => $rule->{fetch} } );
587 die "relationship " . $fetch->{rel} . " does not exist for " . $s->source_name unless ($related_rs);
588 if ($fetch->{cond} and ref $fetch->{cond} eq 'HASH') {
589 # if value starts with / assume it's meant to be passed as a scalar ref to dbic
590 # ideally this would substitute deeply
591 $fetch->{cond} = { map { $_ => ($fetch->{cond}->{$_} =~ s/^\\//) ? \$fetch->{cond}->{$_} : $fetch->{cond}->{$_} } keys %{$fetch->{cond}} };
593 $related_rs = $related_rs->search($fetch->{cond}, { join => $fetch->{join} }) if ($fetch->{cond});
594 $related_rs = $related_rs->search({}, { rows => $fetch->{quantity} }) if ($fetch->{quantity} && $fetch->{quantity} ne 'all');
595 $related_rs = $related_rs->search({}, { order_by => $fetch->{order_by} }) if ($fetch->{order_by});
596 $self->dump_object($_, { %{$params}, set => $fetch }) foreach $related_rs->all;
600 sub _generate_schema {
602 my $params = shift || {};
604 $self->msg("\ncreating schema");
605 # die 'must pass version param to generate_schema_from_ddl' unless $params->{version};
607 my $schema_class = $self->schema_class || "DBIx::Class::Fixtures::Schema";
608 eval "require $schema_class";
612 my $connection_details = $params->{connection_details};
613 unless( $pre_schema = $schema_class->connect(@{$connection_details}) ) {
614 return DBIx::Class::Exception->throw('connection details not valid');
616 my @tables = map { $pre_schema->source($_)->from }$pre_schema->sources;
617 my $dbh = $pre_schema->storage->dbh;
620 $self->msg("- clearing DB of existing tables");
621 eval { $dbh->do('SET foreign_key_checks=0') };
622 $dbh->do('drop table ' . $_) for (@tables);
624 # import new ddl file to db
625 my $ddl_file = $params->{ddl};
626 $self->msg("- deploying schema using $ddl_file");
628 open $fh, "<$ddl_file" or die ("Can't open DDL file, $ddl_file ($!)");
629 my @data = split(/\n/, join('', <$fh>));
630 @data = grep(!/^--/, @data);
631 @data = split(/;/, join('', @data));
633 @data = grep { $_ && $_ !~ /^-- / } @data;
635 eval { $dbh->do($_) or warn "SQL was:\n $_"};
636 if ($@) { die "SQL was:\n $_\n$@"; }
638 $self->msg("- finished importing DDL into DB");
640 # load schema object from our new DB
641 $self->msg("- loading fresh DBIC object from DB");
642 my $schema = $schema_class->connect(@{$connection_details});
651 =item Arguments: \%$attrs
653 =item Return Value: 1
657 $fixtures->populate({
658 directory => '/home/me/app/fixtures', # directory to look for fixtures in, as specified to dump
659 ddl => '/home/me/app/sql/ddl.sql', # DDL to deploy
660 connection_details => ['dbi:mysql:dbname=app_dev', 'me', 'password'] # database to clear, deploy and then populate
663 In this case the database app_dev will be cleared of all tables, then the specified DDL deployed to it,
664 then finally all fixtures found in /home/me/app/fixtures will be added to it. populate will generate
665 its own DBIx::Class schema from the DDL rather than being passed one to use. This is better as
666 custom insert methods are avoided which can to get in the way. In some cases you might not
667 have a DDL, and so this method will eventually allow a $schema object to be passed instead.
669 directory, dll and connection_details are all required attributes.
676 unless (ref $params eq 'HASH') {
677 return DBIx::Class::Exception->throw('first arg to populate must be hash ref');
680 foreach my $param (qw/directory/) {
681 unless ($params->{$param}) {
682 return DBIx::Class::Exception->throw($param . ' param not specified');
685 my $fixture_dir = dir(delete $params->{directory});
686 unless (-e $fixture_dir) {
687 return DBIx::Class::Exception->throw('fixture directory does not exist at ' . $fixture_dir);
692 if ($params->{ddl} && $params->{connection_details}) {
693 $ddl_file = file(delete $params->{ddl});
694 unless (-e $ddl_file) {
695 return DBIx::Class::Exception->throw('DDL does not exist at ' . $ddl_file);
697 unless (ref $params->{connection_details} eq 'ARRAY') {
698 return DBIx::Class::Exception->throw('connection details must be an arrayref');
700 } elsif ($params->{schema}) {
701 return DBIx::Class::Exception->throw('passing a schema is not supported at the moment');
703 return DBIx::Class::Exception->throw('you must set the ddl and connection_details params');
706 my $schema = $self->_generate_schema({ ddl => $ddl_file, connection_details => delete $params->{connection_details}, %{$params} });
707 $self->msg("\nimporting fixtures");
708 my $tmp_fixture_dir = dir($fixture_dir, "-~populate~-" . $<);
710 my $version_file = file($fixture_dir, '_dumper_version');
711 unless (-e $version_file) {
712 # return DBIx::Class::Exception->throw('no version file found');
715 if (-e $tmp_fixture_dir) {
716 $self->msg("- deleting existing temp directory $tmp_fixture_dir");
717 $tmp_fixture_dir->rmtree;
719 $self->msg("- creating temp dir");
720 dircopy(dir($fixture_dir, $schema->source($_)->from), dir($tmp_fixture_dir, $schema->source($_)->from)) for $schema->sources;
722 eval { $schema->storage->dbh->do('SET foreign_key_checks=0') };
725 my $formatter= $schema->storage->datetime_parser;
726 unless ($@ || !$formatter) {
728 if ($params->{datetime_relative_to}) {
729 $callbacks{'DateTime::Duration'} = sub {
730 $params->{datetime_relative_to}->clone->add_duration($_);
733 $callbacks{'DateTime::Duration'} = sub {
734 $formatter->format_datetime(DateTime->today->add_duration($_))
737 $callbacks{object} ||= "visit_ref";
738 $fixup_visitor = new Data::Visitor::Callback(%callbacks);
740 foreach my $source (sort $schema->sources) {
741 $self->msg("- adding " . $source);
742 my $rs = $schema->resultset($source);
743 my $source_dir = dir($tmp_fixture_dir, lc($rs->result_source->from));
744 next unless (-e $source_dir);
745 while (my $file = $source_dir->next) {
746 next unless ($file =~ /\.fix$/);
747 next if $file->is_dir;
748 my $contents = $file->slurp;
751 $HASH1 = $fixup_visitor->visit($HASH1) if $fixup_visitor;
756 $self->msg("- fixtures imported");
757 $self->msg("- cleaning up");
758 $tmp_fixture_dir->rmtree;
759 eval { $schema->storage->dbh->do('SET foreign_key_checks=1') };
766 my $subject = shift || return;
767 my $level = shift || 1;
769 return unless $self->debug >= $level;
771 print Dumper($subject);
773 print $subject . "\n";
779 Luke Saunders <luke@shadowcatsystems.co.uk>
783 Ash Berlin <ash@shadowcatsystems.co.uk>
784 Matt S. Trout <mst@shadowcatsystems.co.uk>