————————————package
Module::Build::DB;
use
strict;
use
warnings;
our
$VERSION
=
'0.10'
;
=head1 Name
Module::Build::DB - Build, configure, and test database-backed applications
=head1 Synopsis
In F<Build.PL>:
use strict;
use Module::Build::DB;
Module::Build::DB->new(
module_name => 'MyApp',
db_config_key => 'dbi',
context => 'test',
)->create_build_script;
On the command-line:
perl Build.PL
./Build --db_super_user postgres
./Build db --context test
./Build test
=head1 Description
This module subclasses L<Module::Build> to provide added functionality for
configuring, building, and testing database-backed applications. It uses a
simple Rails-style numbered migration scheme, although migration scripts are
written in pure SQL, not Perl.
Frankly, this isn't a great module. Some reasons:
=over
=item *
The numbered method of tracking migration dependencies has very little
flexibility.
=item *
Subclassing Module::Build is a really bad way to extend the build system,
because you can't really mix in other build features.
=back
Someday, I hope to fix the first issue by looking more closely at L<database
change
and perhaps by adopting a L<completely different
approach|http://www.depesz.com/index.php/2010/08/22/versioning/>. The latter
problem I would likely solve by completely separating the migration code from
the build system, and then integrating as appropriate (hopefully Module::Build
will get proper plugins someday).
But in the meantime, I have working code that depends on this simple
implementation (which does support L<PostgreSQL|http://www.postgresql.org/>,
L<SQLite|http://www.sqlite.org/> and L<MySQL|http://www.mysql.com/>), and I
want it to be easy for people to get at this dependency. So here we are.
=cut
##############################################################################
=head1 Class Interface
=head2 Properties
Module::Build::DB defines these properties in addition to those specified by
L<Module::Build|Module::Build>. Note that these may be specified either in
F<Build.PL> or on the command-line.
=head3 context
perl Build.PL --context test
Specifies the context in which the build will run. The context associates the
build with a configuration file, and therefore must be named for a
configuration file your project. For example, to build in the "dev" context,
there must be a F<dev.yml> file (or F<dev.json> or some other format supported
by L<Config::Any>) in the F<conf/> or F<etc/> directory of your project.
Defaults to "test", which is also the only required context.
=head3 db_client
perl Build.PL --db_client /usr/local/pgsql/bin/pgsql
Specifies the location of the database command-line client. Defaults to
F<psql>, F<mysql>, or F<sqlite3>, depending on the value of the DSN in the
context configuration file.
=head3 drop_db
./Build db --drop_db 1
Tells the L</"db"> action to drop the database and build a new one. When this
property is set to a false value (the default), an existing database for the
current context will not be dropped, but it will be brought up-to-date by
C<./Build db>.
=head3 db_config_key
The config key under which DBI configuration is stored in the configuration
file. Defaults to "dbi". The keys that should be under this configuration key
are:
=over
=item * C<dsn>
=item * C<username>
=item * C<password>
=back
=head3 db_super_user
=head3 db_super_pass
perl Build.PL --db_super_user root --db_super_pass s3cr1t
Specifies a super user and password to be used to connect to the database.
This is important if you need to use a different database user to create and
update the database than to run your app. Most likely you'll use this for
production deployments. If not specified the user name and password from the
the context configuration file will be used.
=head3 test_env
./Build db --test_env CATALYST_DEBUG=0 CATALYST_CONFIG=conf/test.json
Optional hash reference of environment variables to set for the lifetime of
C<./Build test>. This can be useful for making Catalyst less verbose, for
example. Another use is to tell PostgreSQL where to find pgTAP functions when
they're installed in a schema outside the normal search path in your database:
./Build db --test_env PGOPTIONS='--search_path=tap,public'
=head3 meta_table
./Build db --meta_table mymeta
The name of the metadata table that Module::Build::DB uses to track migrations
in the database. Defaults to "metadata". Change if that name conflicts with
other objects in your application's database, but use only characters that
don't require quoting in the database (e.g., "my_meta" but not "my meta").
=head3 replace_config
Module::Build::DB->new(
module_name => 'MyApp',
db_config_key => 'dbi',
replace_config => 'conf/dev.json',
)->create_build_script;
Set to a string or regular expression (using C<qr//>) and, the C<module_name>
file will be opened during C<./Build> and matching strings replaced with name
of the context configuration file. This is useful when deploying Catalyst
applications, for example, where your C<module_name> file might have something
like this in it:
__PACKAGE__->config(
name => 'MyApp',
'Plugin::ConfigLoader' => { file => 'conf/dev.json' },
);
The C<conf/dev.json> string would be replaced in the copy of the file in
F<blib/lib> with the context configuration file name. Use a regular expression
if you want to cover a variety of values, as in:
replace_config => qr{etc/[^.].json},
=head3 named
./Build migration --named create_users
A string to use when creating a new migration file. The above command would
create a file named F<sql/$time-create_users.sql>.
=cut
__PACKAGE__->add_property(
context
=>
'test'
);
__PACKAGE__->add_property(
replace_config
=>
undef
);
__PACKAGE__->add_property(
db_config_key
=>
'dbi'
);
__PACKAGE__->add_property(
db_client
=>
undef
);
__PACKAGE__->add_property(
drop_db
=> 0 );
__PACKAGE__->add_property(
db_super_user
=>
undef
);
__PACKAGE__->add_property(
db_super_pass
=>
undef
);
__PACKAGE__->add_property(
test_env
=> {} );
__PACKAGE__->add_property(
meta_table
=>
'metadata'
);
__PACKAGE__->add_property(
named
=>
undef
);
##############################################################################
=head2 Actions
=head3 test
=begin comment
=head3 ACTION_test
=end comment
Overrides the default implementation to ensure that tests are only run in the
"test" context, to make sure that the database is up-to-date, and to set up
the test environment with values stored in C<test_env>.
=cut
sub
ACTION_test {
my
$self
=
shift
;
die
qq{ERROR: Tests can only be run in the "test" context\n}
.
"Try `./Build test --context test`\n"
unless
$self
->context eq
'test'
;
# Make sure the database is up-to-date.
$self
->depends_on(
'db'
);
# Tell the tests where to find stuff, like pgTAP.
local
%ENV
= (
%ENV
, %{
$self
->test_env } );
# Make it so.
$self
->SUPER::ACTION_test(
@_
);
}
##############################################################################
=head3 migration
=begin comment
=head3 ACTION_migration
=end comment
Creates a new migration script in the F<sql> directory. Best used in
combination with the C<--named> option.
=cut
sub
ACTION_migration {
my
$self
=
shift
;
File::Path::mkpath(
'sql'
);
die
"Can't create directory sql: $!"
unless
-d 'sql';
my
$file
= File::Spec->catfile(
'sql'
,
(
time
.
'-'
.
$self
->named ||
'migration'
) .
'.sql'
);
my
$fh
= IO::File->new(
"> $file"
) or
die
"Can't create $file: $!"
;
$fh
"-- $file SQL Migration\n\n"
;
close
$fh
;
return
$self
;
}
##############################################################################
=head3 config_data
=begin comment
=head3 ACTION_config_data
=end comment
Overrides the default implementation to completely change its behavior. :-)
Rather than creating a whole new configuration file in Module::Build's weird
way, this action now simply opens the application file (that returned by
C<dist_version_from> and replaces all text matching C<replace_config> with the
configuration file for the current context. This means that an installed app
is effectively configured for the proper context at installation time.
=cut
sub
ACTION_config_data {
my
$self
=
shift
;
my
$replace
=
$self
->replace_config or
return
$self
;
my
$file
= File::Spec->catfile(
split
qr{/}
,
$self
->dist_version_from);
my
$blib
= File::Spec->catfile(
$self
->blib,
$file
);
# Die if there is no file
die
qq{ERROR: "$blib" seems to be missing!\n}
unless
-e
$blib
;
# Make sure we have a config file.
$self
->cx_config;
# Figure out where we're going to install this beast.
$file
.=
'.new'
;
my
$new
= File::Spec->catfile(
$self
->blib,
$file
);
my
$config
=
$self
->cx_config_file;
$replace
=
quotemeta
$replace
unless
ref
$replace
eq
'Regexp'
;
# Update the file.
open
my
$orig
,
'<'
,
$blib
or
die
qq{Cannot open "$blib": $!\n}
;
open
my
$temp
,
'>'
,
$new
or
die
qq{Cannot open "$new": $!\n}
;
while
(<
$orig
>) {
s/
$replace
/
$config
/g;
$temp
$_
;
}
close
$orig
;
close
$temp
;
# Make the switch.
rename
$new
,
$blib
or
die
"Cannot rename '$blib' to '$new': $!\n"
;
my
$mode
=
oct
(444) | (
$self
->is_executable(
$blib
) ?
oct
(111) : 0 );
chmod
$mode
,
$blib
;
return
$self
;
}
##############################################################################
=head3 db
=begin comment
=head3 ACTION_db
=end comment
This action creates or updates the database for the current context. If
C<drop_db> is set to a true value, the database will be dropped and created
anew. Otherwise, if the database already exists, it will be brought up-to-date
from the files in the F<sql> directory.
Those files are expected to all be SQL scripts. They must all start with a
number followed by a dash. The number indicates the order in which the scripts
should be run. For example, you might have SQL files like so:
sql/001-types.sql
sql/002-tables.sql
sql/003-triggers.sql
sql/004-functions.sql
sql/005-indexes.sql
The SQL files will be run in integer order to build or update the database.
Module::Build::DB will track the current schema update number corresponding to
the last run SQL script in the C<metadata> table in the database.
If any of the scripts has an error, Module::Build::DB will immediately exit with
the relevant error. To prevent half-way applied updates, the SQL scripts
should use transactions as appropriate.
=cut
sub
ACTION_db {
my
$self
=
shift
;
# Get the database configuration information.
my
$config
=
$self
->cx_config;
my
(
$db
,
$cmd
) =
$self
->db_cmd(
$config
->{
$self
->db_config_key} );
# Does the database exist?
my
$db_exists
=
$self
->drop_db ? 1 :
$self
->_probe(
$self
->{driver}->get_check_db_command(
$cmd
,
$db
)
);
if
(
$db_exists
) {
# Drop the existing database?
if
(
$self
->drop_db ) {
$self
->log_info(
qq{Dropping the "$db" database\n}
);
$self
->do_system(
$self
->{driver}->get_drop_db_command(
$cmd
,
$db
)
) or
die
;
}
else
{
# Just run the upgrades and be done with it.
$self
->upgrade_db(
$db
,
$cmd
);
return
;
}
}
# Now create the database and run all of the SQL files.
$self
->log_info(
qq{Creating the "$db" database\n}
);
$self
->do_system(
$self
->{driver}->get_create_db_command(
$cmd
,
$db
) ) or
die
;
# Add the metadata table and run all of the schema scripts.
$self
->create_meta_table(
$db
,
$cmd
);
$self
->upgrade_db(
$db
,
$cmd
);
}
##############################################################################
=head2 Instance Methods
=head3 cx_config
my $config = $build->cx_config;
Uses L<Config::Any|Config::Any> to read and return the contents of the current
context's configuration file.
=cut
sub
cx_config {
my
$self
=
shift
;
return
$self
->{cx_config}
if
$self
->{cx_config};
my
@stems
=
map
{
File::Spec->catfile(
$_
=>
$self
->context )
}
qw(conf etc)
;
my
$cfg
= Config::Any->load_stems({
stems
=> \
@stems
,
use_ext
=> 1 })->[0];
my
(
$file
,
$config
) = %{
$cfg
};
$self
->cx_config_file(
$file
);
return
$self
->{cx_config} =
$config
;
}
=head3 cx_config_file
my $config_file = $build->cx_config_file;
Returns the name of the context configuration file loaded by C<cx_config>. If
C<cx_config> has not yet been called and loaded a file, it will be.
=cut
sub
cx_config_file {
my
$self
=
shift
;
return
$self
->{cx_config_file} =
shift
if
@_
;
$self
->cx_config;
# Make sure we've found the file.
return
$self
->{cx_config_file};
}
=head3 db_cmd
my ($db_name, $db_cmd) = $build->db_cmd($db_config);
Uses the current context's configuration to determine all of the options to
run the C<db_client> for building the database. Returns the name of the
database and an array ref representing the C<db_client> command and all of its
options, suitable for passing to C<system>. The database name is not included
so as to enable connecting to another database (e.g., template1 on PostgreSQL)
to create the database.
=cut
sub
db_cmd {
my
(
$self
,
$dconf
) =
@_
;
return
@{
$self
}{
qw(db_name db_cmd)
}
if
$self
->{db_cmd} &&
$self
->{db_name};
my
(
undef
,
$driver
,
undef
,
undef
,
$driver_dsn
) = DBI->parse_dsn(
$dconf
->{dsn});
my
%dsn
=
map
{
split
/=/ }
split
/;/,
$driver_dsn
;
$driver
= __PACKAGE__ .
"D::$driver"
;
eval
"require $driver"
or
die
$@ ||
"Package $driver did not return a true value\n"
;
# Make sure we have a client.
$self
->db_client(
$driver
->get_client )
unless
$self
->db_client;
my
(
$db
,
$cmd
) =
$driver
->get_db_and_command(
$self
->db_client, {
%{
$dconf
},
%dsn
,
db_super_user
=>
$self
->db_super_user,
db_super_pass
=>
$self
->db_super_pass,
});
$self
->{db_cmd} =
$cmd
;
$self
->{db_name} =
$db
;
$self
->{driver} =
$driver
;
return
(
$db
,
$cmd
);
}
##############################################################################
=head3 create_meta_table
my ($db_name, $db_cmd ) = $build->db_cmd;
$build->create_meta_table( $db_name, $db_cmd );
Creates the C<metadata> table, which Module::Build::DB uses to track the current
schema version (corresponding to update numbers on the SQL scripts in F<sql>
and other application metadata. If the table already exists, it will be
dropped and recreated. One row is initially inserted, setting the
"schema_version" to 0.
=cut
sub
create_meta_table {
my
(
$self
,
$db
,
$cmd
) =
@_
;
my
$quiet
=
$self
->quiet;
$self
->quiet(1)
unless
$quiet
;
my
$driver
=
$self
->{driver};
$self
->do_system(
$driver
->get_execute_command(
$cmd
,
$db
,
$driver
->get_meta_table_sql(
$self
->meta_table),
)) or
die
;
my
$table
=
$self
->meta_table;
$self
->do_system(
$driver
->get_execute_command(
$cmd
,
$db
,
qq{
INSERT INTO $table VALUES ( 'schema_version', 0, '' );
}
)) or
die
;
$self
->quiet(0)
unless
$quiet
;
}
##############################################################################
=head3 upgrade_db
my ($db_name, $db_cmd ) = $build->db_cmd;
push $db_cmd, '--dbname', $db_name;
$self->upgrade_db( $db_name, $db_cmd );
Upgrades the database using all of the schema files in the F<sql> directory,
applying each in numeric order, setting the schema version upon the success of
each, and exiting upon any error.
=cut
sub
upgrade_db {
my
(
$self
,
$db
,
$cmd
) =
@_
;
$self
->log_info(
qq{Updating the "$db" database\n}
);
my
$driver
=
$self
->{driver};
my
$table
=
$self
->meta_table;
# Get the current version number of the schema.
my
$curr_version
=
$self
->_probe(
$driver
->get_execute_command(
$cmd
,
$db
,
qq{SELECT value FROM $table WHERE label = 'schema_version'}
,
)
);
my
$quiet
=
$self
->quiet;
# Apply all relevant upgrade files.
for
my
$sql
(
sort
grep
{ -f }
glob
'sql/[0-9]*-*.sql'
) {
# Compare upgrade version numbers.
(
my
$new_version
=
$sql
) =~ s{^sql[/\\](\d+)-.+}{$1};
next
unless
$new_version
>
$curr_version
;
# Apply the version.
$self
->do_system(
$driver
->get_file_command(
$cmd
,
$db
,
$sql
) ) or
die
;
$self
->quiet(1)
unless
$quiet
;
$self
->do_system(
$driver
->get_execute_command(
$cmd
,
$db
,
qq{
UPDATE $table
SET value = $new_version
WHERE label = 'schema_version'
}
)) or
die
;
$self
->quiet(0)
unless
$quiet
;
}
}
sub
_probe {
my
$self
=
shift
;
my
$ret
=
$self
->_backticks(
@_
);
chomp
$ret
;
return
$ret
;
}
1;
__END__
=head1 Author
David E. Wheeler <david@justatheory.com>
=head1 Copyright
Copyright (c) 2008-2010 David E. Wheeler. Some Rights Reserved.
This module is free software; you can redistribute it and/or modify it under
the same terms as Perl itself.