1 package DBIx::Class::Fixtures;
6 use DBIx::Class::Exception;
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);
22 'mysql' => 'DateTime::Format::MySQL',
23 'pg' => 'DateTime::Format::Pg',
26 __PACKAGE__->mk_accessors(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) at any time. Use as a constant dataset for running tests against or for populating development databases when impractical to use production clones. Describe fixture set using relations and conditions based on your DBIx::Class schema.
64 =head1 DEFINE YOUR FIXTURE SET
66 Fixture sets are currently defined in .json files which must reside in your config_dir (e.g. /home/me/app/fixture_configs/a_fixture_set.json). They describe which data to pull and dump from the source database.
84 This will fetch artists with primary keys 1 and 3, the producer with primary key 5 and two of producer 5's artists where 'artists' is a has_many DBIx::Class rel from Producer to Artist.
86 The top level attributes are as follows:
90 Sets must be an array of hashes, as in the example given above. Each set defines a set of objects to be included in the fixtures. For details on valid set attributes see L</SET ATTRIBUTES> below.
94 Rules place general conditions on classes. For example if whenever an artist was dumped you also wanted all of their cds dumped too, then you could use a rule to specify this. For example:
118 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 has_many DBIx::Class relation from Artist to CD. This is eqivalent to:
142 rules must be a hash keyed by class name.
146 =head2 datetime_relative
148 Only available for MySQL and PostgreSQL at the moment, must be a value that DateTime::Format::* can parse. For example:
152 class: 'RecentItems',
155 datetime_relative : "2007-10-30 00:00:00"
158 This will work when dumping from a MySQL database and will cause any datetime fields (where datatype => 'datetime' in the column def of the schema class) to be dumped as a DateTime::Duration object relative to the date specified in the datetime_relative value. For example if the RecentItem object had a date field set to 2007-10-25, then when the fixture is imported the field will be set to 5 days in the past relative to the current time.
162 Specifies whether to automatically dump might_have relationships. Should be a hash with one attribute - fetch. Set fetch to 1 or 0.
177 Note: belongs_to rels are automatically dumped whether you like it or not, this is to avoid FKs to nowhere when importing. General rules on has_many rels are not accepted at this top level, but you can turn them on for individual sets - see L</SET ATTRIBUTES>.
179 =head1 SET ATTRIBUTES
183 Required attribute. Specifies the DBIx::Class object class you wish to dump.
187 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 just won't get dumped, no warnings or death.
191 Must be either an integer or the string 'all'. Specifying an integer will effectively set the 'rows' attribute on the resultset clause, specifying 'all' will cause the rows attribute to be left off and for all matching rows to be dumped. There's no randomising here, it's just the first x rows.
195 A hash specifying the conditions dumped objects must match. Essentially this is a JSON representation of a DBIx::Class search clause. For example:
201 cond: { name: 'Dave' }
205 This will dump all artists whose name is 'dave'. Essentially $artist_rs->search({ name => 'Dave' })->all.
207 Sometimes in a search clause it's useful to use scalar refs to do things like:
209 $artist_rs->search({ no1_singles => \'> no1_albums' })
211 This could be specified in the cond hash like so:
217 cond: { no1_singles: '\> no1_albums' }
221 So if the value starts with a backslash the value is made a scalar ref before being passed to search.
225 An array of relationships to be used in the cond clause.
231 cond: { 'cds.position': { '>': 4 } },
236 Fetch all artists who have cds with position greater than 4.
240 Must be an array of hashes. Specifies which rels to also dump. For example:
249 cond: { position: '2' }
254 Will cause the cds of artists 1 and 3 to be dumped where the cd position is 2.
256 Valid attributes are: 'rel', 'quantity', 'cond', 'has_many', 'might_have' and 'join'. rel is the name of the DBIx::Class rel to follow, the rest are the same as in the set attributes. quantity is necessary for has_many relationships, but not if using for belongs_to or might_have relationships.
260 Specifies whether to fetch has_many rels for this set. Must be a hash containing keys fetch and quantity.
262 Set fetch to 1 if you want to fetch them, and quantity to either 'all' or an integer.
264 Be careful here, dumping has_many rels can lead to a lot of data being dumped.
268 As with has_many but for might_have relationships. Quantity doesn't do anything in this case.
270 This value will be inherited by all fetches in this set. This is not true for the has_many attribute.
272 =head1 RULE ATTRIBUTES
276 Same as with L</SET ATTRIBUTES>
280 Same as with L</SET ATTRIBUTES>
284 Same as with L</SET ATTRIBUTES>
288 Same as with L</SET ATTRIBUTES>
292 Same as with L</SET ATTRIBUTES>
300 =item Arguments: \%$attrs
302 =item Return Value: $fixture_object
306 Returns a new DBIx::Class::Fixture object. %attrs has only two valid keys at the
307 moment - 'debug' which determines whether to be verbose and 'config_dir' which is required and much contain a valid path to
308 the directory in which your .json configs reside.
310 my $fixtures = DBIx::Class::Fixtures->new({ config_dir => '/home/me/app/fixture_configs' });
318 unless (ref $params eq 'HASH') {
319 return DBIx::Class::Exception->throw('first arg to DBIx::Class::Fixtures->new() must be hash ref');
322 unless ($params->{config_dir}) {
323 return DBIx::Class::Exception->throw('config_dir param not specified');
326 my $config_dir = dir($params->{config_dir});
327 unless (-e $params->{config_dir}) {
328 return DBIx::Class::Exception->throw('config_dir directory doesn\'t exist');
332 config_dir => $config_dir,
333 _inherited_attributes => [qw/datetime_relative might_have rules/],
334 debug => $params->{debug}
346 =item Arguments: \%$attrs
348 =item Return Value: 1
353 config => 'set_config.json', # config file to use. must be in the config directory specified in the constructor
354 schema => $source_dbic_schema,
355 directory => '/home/me/app/fixtures' # output directory
358 In this case objects will be dumped to subdirectories in the specified directory. For example:
360 /home/me/app/fixtures/artist/1.fix
361 /home/me/app/fixtures/artist/3.fix
362 /home/me/app/fixtures/producer/5.fix
364 config, schema and directory are all required attributes.
372 unless (ref $params eq 'HASH') {
373 return DBIx::Class::Exception->throw('first arg to dump must be hash ref');
376 foreach my $param (qw/config schema directory/) {
377 unless ($params->{$param}) {
378 return DBIx::Class::Exception->throw($param . ' param not specified');
382 my $config_file = file($self->config_dir, $params->{config});
383 unless (-e $config_file) {
384 return DBIx::Class::Exception->throw('config does not exist at ' . $config_file);
387 my $config = Config::Any::JSON->load($config_file);
388 unless ($config && $config->{sets} && ref $config->{sets} eq 'ARRAY' && scalar(@{$config->{sets}})) {
389 return DBIx::Class::Exception->throw('config has no sets');
392 my $output_dir = dir($params->{directory});
393 unless (-e $output_dir) {
394 return DBIx::Class::Exception->throw('output directory does not exist at ' . $output_dir);
397 my $schema = $params->{schema};
399 $self->msg("generating fixtures");
400 my $tmp_output_dir = dir($output_dir, '-~dump~-');
402 if (-e $tmp_output_dir) {
403 $self->msg("- clearing existing $tmp_output_dir");
404 $tmp_output_dir->rmtree;
406 $self->msg("- creating $tmp_output_dir");
407 $tmp_output_dir->mkpath;
409 # write version file (for the potential benefit of populate)
410 my $version_file = file($tmp_output_dir, '_dumper_version');
411 write_file($version_file->stringify, $VERSION);
413 $config->{rules} ||= {};
414 my @sources = sort { $a->{class} cmp $b->{class} } @{delete $config->{sets}};
415 my %options = ( is_root => 1 );
416 foreach my $source (@sources) {
417 # apply rule to set if specified
418 my $rule = $config->{rules}->{$source->{class}};
419 $source = merge( $source, $rule ) if ($rule);
422 my $rs = $schema->resultset($source->{class});
423 $rs = $rs->search($source->{cond}, { join => $source->{join} }) if ($source->{cond});
424 $self->msg("- dumping $source->{class}");
426 my %source_options = ( set => { %{$config}, %{$source} } );
427 if ($source->{quantity}) {
428 $rs = $rs->search({}, { order_by => $source->{order_by} }) if ($source->{order_by});
429 if ($source->{quantity} eq 'all') {
430 push (@objects, $rs->all);
431 } elsif ($source->{quantity} =~ /^\d+$/) {
432 push (@objects, $rs->search({}, { rows => $source->{quantity} }));
434 DBIx::Class::Exception->throw('invalid value for quantity - ' . $source->{quantity});
437 if ($source->{ids}) {
438 my @ids = @{$source->{ids}};
439 my @id_objects = grep { $_ } map { $rs->find($_) } @ids;
440 push (@objects, @id_objects);
442 unless ($source->{quantity} || $source->{ids}) {
443 DBIx::Class::Exception->throw('must specify either quantity or ids');
447 foreach my $object (@objects) {
448 $source_options{set_dir} = $tmp_output_dir;
449 $self->dump_object($object, { %options, %source_options } );
454 foreach my $dir ($output_dir->children) {
455 next if ($dir eq $tmp_output_dir);
456 $dir->remove || $dir->rmtree;
459 $self->msg("- moving temp dir to $output_dir");
460 move($_, dir($output_dir, $_->relative($_->parent)->stringify)) for $tmp_output_dir->children;
461 if (-e $output_dir) {
462 $self->msg("- clearing tmp dir $tmp_output_dir");
463 # delete existing fixture set
464 $tmp_output_dir->remove;
473 my ($self, $object, $params, $rr_info) = @_;
474 my $set = $params->{set};
475 die 'no dir passed to dump_object' unless $params->{set_dir};
476 die 'no object passed to dump_object' unless $object;
478 my @inherited_attrs = @{$self->_inherited_attributes};
480 # write dir and gen filename
481 my $source_dir = dir($params->{set_dir}, lc($object->result_source->from));
482 mkdir($source_dir->stringify, 0777);
483 my $file = file($source_dir, join('-', map { $object->get_column($_) } sort $object->primary_columns) . '.fix');
486 my $exists = (-e $file->stringify) ? 1 : 0;
488 $self->msg('-- dumping ' . $file->stringify, 2);
489 my %ds = $object->get_columns;
491 my $driver = $object->result_source->schema->storage->dbh->{Driver}->{Name};
492 my $formatter= $db_to_parser{$driver};
493 eval "require $formatter" if ($formatter);
495 # mess with dates if specified
496 if ($set->{datetime_relative}) {
497 unless ($@ || !$formatter) {
499 if ($set->{datetime_relative} eq 'today') {
500 $dt = DateTime->today;
502 $dt = $formatter->parse_datetime($set->{datetime_relative}) unless ($@);
505 while (my ($col, $value) = each %ds) {
506 my $col_info = $object->result_source->column_info($col);
509 && $col_info->{_inflate_info}
510 && uc($col_info->{data_type}) eq 'DATETIME';
512 $ds{$col} = $object->get_inflated_column($col)->subtract_datetime($dt);
515 warn "datetime_relative not supported for $driver at the moment";
519 # do the actual dumping
520 my $serialized = Dump(\%ds)->Out();
521 write_file($file->stringify, $serialized);
522 my $mode = 0777; chmod $mode, $file->stringify;
525 # dump rels of object
526 my $s = $object->result_source;
528 foreach my $name (sort $s->relationships) {
529 my $info = $s->relationship_info($name);
530 my $r_source = $s->related_source($name);
531 # if belongs_to or might_have with might_have param set or has_many with has_many param set then
532 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}))) {
533 my $related_rs = $object->related_resultset($name);
534 my $rule = $set->{rules}->{$related_rs->result_source->source_name};
535 # these parts of the rule only apply to has_many rels
536 if ($rule && $info->{attrs}{accessor} eq 'multi') {
537 $related_rs = $related_rs->search($rule->{cond}, { join => $rule->{join} }) if ($rule->{cond});
538 $related_rs = $related_rs->search({}, { rows => $rule->{quantity} }) if ($rule->{quantity} && $rule->{quantity} ne 'all');
539 $related_rs = $related_rs->search({}, { order_by => $rule->{order_by} }) if ($rule->{order_by});
541 if ($set->{has_many}->{quantity} && $set->{has_many}->{quantity} =~ /^\d+$/) {
542 $related_rs = $related_rs->search({}, { rows => $set->{has_many}->{quantity} });
544 my %c_params = %{$params};
546 my %mock_set = map { $_ => $set->{$_} } grep { $set->{$_} } @inherited_attrs;
547 $c_params{set} = \%mock_set;
548 # use Data::Dumper; print ' -- ' . Dumper($c_params{set}, $rule->{fetch}) if ($rule && $rule->{fetch});
549 $c_params{set} = merge( $c_params{set}, $rule) if ($rule && $rule->{fetch});
550 # use Data::Dumper; print ' -- ' . Dumper(\%c_params) if ($rule && $rule->{fetch});
551 $self->dump_object($_, \%c_params) foreach $related_rs->all;
556 return unless $set && $set->{fetch};
557 foreach my $fetch (@{$set->{fetch}}) {
559 $fetch->{$_} = $set->{$_} foreach grep { !$fetch->{$_} && $set->{$_} } @inherited_attrs;
560 my $related_rs = $object->related_resultset($fetch->{rel});
561 my $rule = $set->{rules}->{$related_rs->result_source->source_name};
563 my $info = $object->result_source->relationship_info($fetch->{rel});
564 if ($info->{attrs}{accessor} eq 'multi') {
565 $fetch = merge( $fetch, $rule );
566 } elsif ($rule->{fetch}) {
567 $fetch = merge( $fetch, { fetch => $rule->{fetch} } );
570 die "relationship " . $fetch->{rel} . " does not exist for " . $s->source_name unless ($related_rs);
571 if ($fetch->{cond} and ref $fetch->{cond} eq 'HASH') {
572 # if value starts with / assume it's meant to be passed as a scalar ref to dbic
573 # ideally this would substitute deeply
574 $fetch->{cond} = { map { $_ => ($fetch->{cond}->{$_} =~ s/^\\//) ? \$fetch->{cond}->{$_} : $fetch->{cond}->{$_} } keys %{$fetch->{cond}} };
576 $related_rs = $related_rs->search($fetch->{cond}, { join => $fetch->{join} }) if ($fetch->{cond});
577 $related_rs = $related_rs->search({}, { rows => $fetch->{quantity} }) if ($fetch->{quantity} && $fetch->{quantity} ne 'all');
578 $related_rs = $related_rs->search({}, { order_by => $fetch->{order_by} }) if ($fetch->{order_by});
579 $self->dump_object($_, { %{$params}, set => $fetch }) foreach $related_rs->all;
583 sub _generate_schema {
585 my $params = shift || {};
587 $self->msg("\ncreating schema");
588 # die 'must pass version param to generate_schema_from_ddl' unless $params->{version};
590 my $schema_class = $self->schema_class || "DBIx::Class::Fixtures::Schema";
591 eval "require $schema_class";
595 my $connection_details = $params->{connection_details};
596 unless( $pre_schema = $schema_class->connect(@{$connection_details}) ) {
597 return DBIx::Class::Exception->throw('connection details not valid');
599 my @tables = map { $pre_schema->source($_)->from }$pre_schema->sources;
600 my $dbh = $pre_schema->storage->dbh;
603 $self->msg("- clearing DB of existing tables");
604 eval { $dbh->do('SET foreign_key_checks=0') };
605 $dbh->do('drop table ' . $_) for (@tables);
607 # import new ddl file to db
608 my $ddl_file = $params->{ddl};
609 $self->msg("- deploying schema using $ddl_file");
611 open $fh, "<$ddl_file" or die ("Can't open DDL file, $ddl_file ($!)");
612 my @data = split(/\n/, join('', <$fh>));
613 @data = grep(!/^--/, @data);
614 @data = split(/;/, join('', @data));
616 @data = grep { $_ && $_ !~ /^-- / } @data;
618 eval { $dbh->do($_) or warn "SQL was:\n $_"};
619 if ($@) { die "SQL was:\n $_\n$@"; }
621 $self->msg("- finished importing DDL into DB");
623 # load schema object from our new DB
624 $self->msg("- loading fresh DBIC object from DB");
625 my $schema = $schema_class->connect(@{$connection_details});
634 =item Arguments: \%$attrs
636 =item Return Value: 1
640 $fixtures->populate({
641 directory => '/home/me/app/fixtures', # directory to look for fixtures in, as specified to dump
642 ddl => '/home/me/app/sql/ddl.sql', # DDL to deploy
643 connection_details => ['dbi:mysql:dbname=app_dev', 'me', 'password'] # database to clear, deploy and then populate
646 In this case the database app_dev will be cleared of all tables, then the specified DDL deployed to it,
647 then finally all fixtures found in /home/me/app/fixtures will be added to it. populate will generate
648 its own DBIx::Class schema from the DDL rather than being passed one to use. This is better as
649 custom insert methods are avoided which can to get in the way. In some cases you might not
650 have a DDL, and so this method will eventually allow a $schema object to be passed instead.
652 directory, dll and connection_details are all required attributes.
659 unless (ref $params eq 'HASH') {
660 return DBIx::Class::Exception->throw('first arg to populate must be hash ref');
663 foreach my $param (qw/directory/) {
664 unless ($params->{$param}) {
665 return DBIx::Class::Exception->throw($param . ' param not specified');
668 my $fixture_dir = dir(delete $params->{directory});
669 unless (-e $fixture_dir) {
670 return DBIx::Class::Exception->throw('fixture directory does not exist at ' . $fixture_dir);
675 if ($params->{ddl} && $params->{connection_details}) {
676 $ddl_file = file(delete $params->{ddl});
677 unless (-e $ddl_file) {
678 return DBIx::Class::Exception->throw('DDL does not exist at ' . $ddl_file);
680 unless (ref $params->{connection_details} eq 'ARRAY') {
681 return DBIx::Class::Exception->throw('connection details must be an arrayref');
683 } elsif ($params->{schema}) {
684 return DBIx::Class::Exception->throw('passing a schema is not supported at the moment');
686 return DBIx::Class::Exception->throw('you must set the ddl and connection_details params');
689 my $schema = $self->_generate_schema({ ddl => $ddl_file, connection_details => delete $params->{connection_details}, %{$params} });
690 $self->msg("\nimporting fixtures");
691 my $tmp_fixture_dir = dir($fixture_dir, "-~populate~-" . $<);
693 my $version_file = file($fixture_dir, '_dumper_version');
694 unless (-e $version_file) {
695 # return DBIx::Class::Exception->throw('no version file found');
698 if (-e $tmp_fixture_dir) {
699 $self->msg("- deleting existing temp directory $tmp_fixture_dir");
700 $tmp_fixture_dir->rmtree;
702 $self->msg("- creating temp dir");
703 dircopy(dir($fixture_dir, $schema->source($_)->from), dir($tmp_fixture_dir, $schema->source($_)->from)) for $schema->sources;
705 eval { $schema->storage->dbh->do('SET foreign_key_checks=0') };
708 my $driver = $schema->storage->dbh->{Driver}->{Name};
709 my $formatter= $db_to_parser{$driver};
710 eval "require $formatter" if ($formatter);
711 unless ($@ || !$formatter) {
713 if ($params->{datetime_relative_to}) {
714 $callbacks{'DateTime::Duration'} = sub {
715 $params->{datetime_relative_to}->clone->add_duration($_);
718 $callbacks{'DateTime::Duration'} = sub {
719 $formatter->format_datetime(DateTime->today->add_duration($_))
722 $callbacks{object} ||= "visit_ref";
723 $fixup_visitor = new Data::Visitor::Callback(%callbacks);
725 foreach my $source (sort $schema->sources) {
726 $self->msg("- adding " . $source);
727 my $rs = $schema->resultset($source);
728 my $source_dir = dir($tmp_fixture_dir, lc($rs->result_source->from));
729 next unless (-e $source_dir);
730 while (my $file = $source_dir->next) {
731 next unless ($file =~ /\.fix$/);
732 next if $file->is_dir;
733 my $contents = $file->slurp;
736 $HASH1 = $fixup_visitor->visit($HASH1) if $fixup_visitor;
741 $self->msg("- fixtures imported");
742 $self->msg("- cleaning up");
743 $tmp_fixture_dir->rmtree;
744 eval { $schema->storage->dbh->do('SET foreign_key_checks=1') };
749 my $subject = shift || return;
750 my $level = shift || 1;
752 return unless $self->debug >= $level;
754 print Dumper($subject);
756 print $subject . "\n";
762 Luke Saunders <luke@shadowcatsystems.co.uk>
766 Ash Berlin <ash@shadowcatsystems.co.uk>
767 Matt S. Trout <mst@shadowcatsystems.co.uk>