The Perl and Raku Conference 2025: Greenville, South Carolina - June 27-29 Learn more

our $VERSION = '0.26';
use feature qw( state );
use Moo;
use DBI ();
use DBI::Const::GetInfoType qw( %GetInfoType );
use Log::Any qw( $Logger );
use String::Expand qw( expand_string );
use Try::Tiny qw( catch try );
use Type::Params qw( signature );
use Types::Common::Numeric qw( PositiveInt );
use Types::Path::Tiny qw( Dir );
use Types::Standard qw( ArrayRef HashRef Str );
use namespace::clean -except => [ qw( before new ) ];
# 1st alternative set of constructor attributes
has dsn => ( is => 'lazy', isa => Str );
sub _build_dsn {
my $self = shift;
return $self->dbh->get_info( $GetInfoType{ SQL_DATA_SOURCE_NAME } );
}
has [ qw( password username ) ] => ( is => 'ro', isa => Str );
# 2nd alternative set of constructor attributes
has dbh => ( is => 'lazy' );
sub _build_dbh {
my $self = shift;
return DBI->connect(
$self->dsn,
$self->username,
$self->password,
{
RaiseError => 1,
PrintError => 0,
AutoCommit => 1 # see below "begin_work" based transaction handling
}
);
}
my $MigrationsDir = Type::Tiny->new(
name => 'MigrationsDir',
parent => Dir,
constraint => sub { __PACKAGE__->latest( $_ ) }
);
has dir => ( is => 'rw', isa => $MigrationsDir, once => 1, coerce => 1 );
has do_before => ( is => 'lazy', isa => ArrayRef [ Str | ArrayRef ], default => sub { [] } );
has do_while => ( is => 'lazy', isa => ArrayRef [ Str | ArrayRef ], default => sub { [] } );
has tracking_table => ( is => 'ro', isa => Str, default => 'dbix_migration' );
has placeholders => ( is => 'lazy', isa => HashRef [ Str ], default => sub { {} }, init_arg => undef );
sub BUILD {
my ( $self, $args ) = @_;
# new() is overloaded: check consistency of attributes
if ( exists $args->{ dsn } ) {
die 'dsn and dbh cannot be used at the same time'
if exists $args->{ dbh };
} elsif ( exists $args->{ dbh } ) {
foreach ( qw( dsn username password ) ) {
die "dbh and $_ cannot be used at the same time"
if exists $args->{ $_ };
}
} else {
die 'both dsn and dbh are not set';
}
# driver should match subclass
my $class = ref $self;
if ( ( my @package = split( /::/, $class ) ) > 2 ) {
my $driver = $self->driver;
die "subclass $class cannot handle $driver driver"
unless $driver eq $package[ -1 ];
}
}
# overrideable
sub create_tracking_table {
my $self = shift;
my $tracking_table = $self->quoted_tracking_table;
$Logger->debugf( "Create tracking table '%s'", $tracking_table );
$self->dbh->do( "CREATE TABLE IF NOT EXISTS $tracking_table ( name VARCHAR(64) PRIMARY KEY, value VARCHAR(64) )" );
}
# can be used as an object method ($dsn not specified) and as a class method
# ($dsn specified)
sub driver {
my ( $self, $dsn ) = @_;
return ( DBI->parse_dsn( defined $dsn ? $dsn : $self->dsn ) )[ 1 ];
}
sub latest {
# coercion is implicitly enabled because the Dir type constraint has a coercion
state $signature = signature( method => 1, positional => [ Dir, { optional => 1 } ] );
my ( $self, $dir ) = $signature->( @_ );
$dir = $self->dir unless defined $dir;
Dir->assert_valid( undef ) unless defined $dir;
my $latest = 0;
$dir->visit(
sub {
return unless m/\D*([1-9][0-9]*)_up\.sql\z/;
$latest = $1 if $1 > $latest;
}
);
return PositiveInt->assert_return( $latest );
}
sub migrate {
my ( $self, $target ) = @_;
Dir->assert_valid( $self->dir );
$target = $self->latest unless defined $target;
my $fatal_error;
my $return_value = try {
# on purpose outside of the transaction
# doesn't use _dbh (the cloned dbh)
$self->create_tracking_table;
$self->{ _dbh } = $self->dbh->clone(
{
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
}
);
$Logger->debugf( "Execute 'before' transaction todo: '%s'", $_ ), $self->{ _dbh }->do( ref eq 'ARRAY' ? @$_ : $_ )
foreach @{ $self->do_before };
$Logger->debug( 'Enable transaction turning AutoCommit off' );
$self->{ _dbh }->begin_work;
$Logger->debugf( "Execute 'while' transaction todo: '%s'", $_ ), $self->{ _dbh }->do( ref eq 'ARRAY' ? @$_ : $_ )
foreach @{ $self->do_while };
my $version = $self->version;
$self->_initialize_tracking_table, $version = 0 unless defined $version;
my @need;
my $type;
if ( $target == $version ) {
$Logger->debugf( 'Database is already at migration version %d', $target );
return 1;
} elsif ( $target > $version ) { # upgrade
$type = 'up';
$version += 1;
@need = $version .. $target;
} else { # downgrade
$type = 'down';
$target += 1;
@need = reverse( $target .. $version );
}
my $files = $self->_files( $type, \@need );
if ( defined $files ) {
for my $file ( @$files ) {
my $name = $file->{ name };
my $ver = $file->{ version };
$Logger->debugf( "Process migration '%s'", $name );
my $content = $name->slurp_raw;
my $delimiter = ( $content =~ m/\A-- *dbix_migration_delimiter: *([[:graph:]])/ ) ? $1 : ';';
$Logger->debugf( "Migration section delimiter is '%s'", $delimiter );
$content =~ s/\s*--.*$//mg;
# split content into sections ($sql)
for my $sql ( split /$delimiter/, $content ) {
$sql =~ s/\A\s*//;
next unless $sql =~ /\w/;
$sql = expand_string( $sql, $self->placeholders );
# prepend $sql to error message
local $self->{ _dbh }->{ HandleError } = sub { $_[ 0 ] = "$sql\n$_[0]"; return 0; };
$self->{ _dbh }->do( $sql );
}
$ver -= 1 if ( ( $ver > 0 ) && ( $type eq 'down' ) );
$self->_update_tracking_table( $ver );
}
return 1;
} else {
my $newver = $self->version;
$Logger->debugf( "Database is at version %d, couldn't migrate to version %d", $newver, $target )
if $target != $newver;
return 0;
}
} catch {
$fatal_error = $_;
};
if ( $fatal_error ) {
$Logger->debug( 'Rollback transaction turning AutoCommit on again' ), $self->{ _dbh }->rollback
if exists $self->{ _dbh };
delete $self->{ _dbh };
# rethrow exception
die $fatal_error;
}
$Logger->debug( 'Commit transaction turning AutoCommit on again' );
$self->{ _dbh }->commit;
delete $self->{ _dbh };
return $return_value;
}
# overrideable
sub quoted_tracking_table {
my $self = shift;
return $self->dbh->quote_identifier( $self->tracking_table );
}
sub version {
my $self = shift;
my $dbh = $self->dbh;
local @{ $dbh }{ qw( RaiseError PrintError ) } = ( 1, 0 );
try {
my $tracking_table = $self->quoted_tracking_table;
$Logger->debugf( "Read tracking table '%s'", $tracking_table );
my $sth = $dbh->prepare( "SELECT value FROM $tracking_table WHERE name = ?" );
$sth->execute( 'version' );
my $version = undef;
for my $val ( $sth->fetchrow_arrayref ) {
$version = $val->[ 0 ];
}
$version;
};
}
sub _files {
my ( $self, $type, $need ) = @_;
my @files;
for my $i ( @$need ) {
no warnings 'uninitialized';
$self->dir->visit(
sub {
return unless m/\D*${i}_$type\.sql\z/;
$Logger->debugf( "Found migration '%s'", $_ );
push @files, { name => $_, version => $i };
}
);
}
return ( @files and @$need == @files ) ? \@files : undef;
}
sub _initialize_tracking_table {
my $self = shift;
my $tracking_table = $self->quoted_tracking_table;
$Logger->debugf( "Initialize tracking table '%s'", $tracking_table );
$self->{ _dbh }->do( "INSERT INTO $tracking_table ( name, value ) VALUES ( ?, ? )", undef, 'version', 0 );
}
sub _update_tracking_table {
my ( $self, $version ) = @_;
my $tracking_table = $self->quoted_tracking_table;
$Logger->debugf( "Update tracking table '%s'", $tracking_table );
$self->{ _dbh }->do( "UPDATE $tracking_table SET value = ? WHERE name = ?", undef, $version, 'version' );
}
1;