File Coverage

blib/lib/DBIx/Auto/Migrate.pm
Criterion Covered Total %
statement 74 87 85.0
branch 7 14 50.0
condition 2 4 50.0
subroutine 17 19 89.4
pod n/a
total 100 124 80.6


line stmt bran cond sub pod time code
1             package DBIx::Auto::Migrate;
2              
3             our $VERSION = "0.8";
4              
5 2     2   1085437 use v5.16.3;
  2         7  
6 2     2   10 use strict;
  2         5  
  2         86  
7 2     2   11 use warnings;
  2         12  
  2         109  
8              
9 2     2   12 use DBI;
  2         5  
  2         396  
10              
11             sub _check_defined_sub {
12 4     4   11 my ( $caller, $sub ) = @_;
13 4 50       27 if ( !$caller->can($sub) ) {
14 0         0 die
15 0         0 "To import '@{[__PACKAGE__]}' in '$caller' you must implement the '$sub' subroutine";
16             }
17             }
18              
19             sub import {
20 3     3   155 my $caller = caller;
21 2     2   13 no strict 'refs';
  2         3  
  2         426  
22 3         268780 *{"${caller}::finish_auto_migrate"} = sub {
23 1     1   114703 _migrations_finish($caller);
24 3         68 };
25             }
26              
27             sub _migrations_finish {
28 1     1   4 my ($caller) = @_;
29 1         5 _check_defined_sub( $caller, 'migrations' );
30 1         4 _check_defined_sub( $caller, 'dsn' );
31 1         4 _check_defined_sub( $caller, 'user' );
32 1         3 _check_defined_sub( $caller, 'pass' );
33 1         2 my $extra;
34 1 50       11 if ( defined( my $extra_sub = $caller->can('extra') ) ) {
35 0         0 $extra = $extra_sub->();
36             }
37 1   50     8 $extra //= {};
38 1         5 my ( $dsn, $user, $pass ) = ( $caller->dsn, $caller->user, $caller->pass );
39              
40 1 50       14 if ( 'HASH' ne ref $extra ) {
41 0         0 die "${caller}::extra should return a hashref or undef";
42             }
43             {
44 2     2   12 no strict 'refs';
  2         4  
  2         74  
  1         2  
45 2     2   10 no warnings 'redefine';
  2         15  
  2         1475  
46 1         6 *{"${caller}::connect"} = sub {
47 1     1   7 _connect_wrapper( $caller, 'connect', $dsn, $user, $pass, $extra );
48 1         6 };
49 1         6 *{"${caller}::connect_cached"} = sub {
50 0     0   0 _connect_wrapper( $caller, 'connect_cached', $dsn, $user, $pass,
51             $extra );
52 1         9 };
53              
54             }
55             }
56              
57             sub _connect_wrapper {
58 1     1   4 my ( $caller, $sub, $dsn, $user, $pass, $extra ) = @_;
59             my $dbh = DBI->can($sub)->(
60             'DBI', $dsn, $user, $pass,
61             {
62             RaiseError => 1,
63             Callbacks => {
64             connected => sub {
65 0     0   0 eval {shift->do('set timezone = UTC')};
  0         0  
66 0         0 return;
67             }
68             },
69 1         34 %$extra,
70             },
71             );
72 1         12 _migrate( $caller, $dbh );
73 1         5 return $dbh;
74             }
75              
76             sub _migrate {
77 1     1   3 my ( $caller, $dbh ) = @_;
78 1         8 local $dbh->{RaiseError} = 0;
79 1         3 local $dbh->{PrintError} = 0;
80 1         13 my @migrations = $caller->can('migrations')->();
81 1 50       39 if ( _get_current_migration($dbh) > @migrations ) {
82 0         0 warn "Something happened there, wrong migration number.";
83             }
84 1 50       4 if ( _get_current_migration($dbh) >= @migrations ) {
85 0         0 say STDERR "Migrations already applied.";
86 0         0 return;
87             }
88 1         5 _apply_migrations( $dbh, \@migrations );
89              
90             }
91              
92             sub _apply_migrations {
93 1     1   23 my $dbh = shift;
94 1         4 my $migrations = shift;
95 1         4 for ( my $i = _get_current_migration($dbh) ; $i < @$migrations ; $i++ ) {
96 3         9 local $dbh->{RaiseError} = 1;
97 3         8 my $current_migration = $migrations->[$i];
98 3         7 my $migration_number = $i + 1;
99 3         7 _apply_migration( $dbh, $current_migration, $migration_number );
100             }
101             }
102              
103             sub _get_current_migration {
104 3     3   29 my $dbh = shift;
105 3         29 my $result = $dbh->selectrow_hashref( <<'EOF', undef, 'current_migration' );
106             SELECT value FROM options WHERE name = ?;
107             EOF
108 3   50     299 return int( $result->{value} // 0 );
109             }
110              
111             sub _apply_migration {
112 3     3   6 my $dbh = shift;
113 3         6 my $current_migration = shift;
114 3         5 my $migration_number = shift;
115             {
116 3         6 eval { $dbh->do($current_migration); };
  3         6  
  3         20  
117 3 50       270 if ($@) {
118 0         0 die "$current_migration\n failed with: $@";
119             }
120             }
121 3         6 my $success = eval {
122 3         20 $dbh->do( <<'EOF', undef, 'current_migration', $migration_number );
123             INSERT INTO options (name, value)
124             VALUES (?, ?)
125             EOF
126 3         272 1;
127             };
128 3 50       20 if (!$success) {
129 0           $dbh->do( <<'EOF', undef, $migration_number, 'current_migration' );
130             UPDATE options
131             SET value = ?
132             WHERE name = ?
133             EOF
134             }
135             }
136             1;
137              
138             =pod
139              
140             =encoding utf-8
141              
142             =head1 NAME
143              
144             DBIx::Auto::Migrate - Wrap your database connections and automatically apply db migrations.
145              
146             =head1 SYNOPSIS
147              
148             package MyCompany::DB;
149            
150             use v5.16.3;
151             use strict;
152             use warnings;
153              
154             use DBIx::Auto::Migrate;
155              
156             finish_auto_migrate;
157              
158             sub create_index {
159             my ($table, $column) = @_;
160             if (!$table) {
161             die 'Index requires table';
162             }
163             if (!$column) {
164             die 'Index requires column';
165             }
166             return "CREATE INDEX index_${table}_${column} ON $table ($column)";
167             }
168              
169             sub migrations {
170             return (
171             'CREATE TABLE options (
172             id BIGSERIAL PRIMARY KEY,
173             name TEXT,
174             value TEXT,
175             UNIQUE (name)
176             )',
177             create_index(qw/options name/),
178             'CREATE TABLE users (
179             id BIGSERIAL PRIMARY KEY,
180             uuid TEXT NOT NULL,
181             username TEXT NOT NULL,
182             name TEXT NOT NULL,
183             surname TEXT NOT NULL,
184             UNIQUE(username)
185             )',
186             create_index(qw/users uuid/),
187             create_index(qw/users username/),
188             );
189             }
190              
191             sub dsn {
192             return 'dbi:Pg:dbname=my_fancy_app_db';
193             }
194              
195             sub user {
196             return 'user';
197             }
198              
199             sub pass {
200             return 'supertopsecretdbpass';
201             }
202              
203             sub extra {
204             {
205             PrintError => 1,
206             }
207             }
208              
209             And elsewhere:
210              
211             my $dbh = MyCompany::DB->connect;
212             my $dbh = MyCompany::DB->connect_cached;
213              
214             =head1 DESCRIPTION
215              
216             Sometimes is convenient to be able to make server or desktop programs that
217             use a database with the ability to be automatically have their database
218             upgraded in runtime.
219              
220             This module comes from a snippet of code I was copying all the time between
221             different projects with different database engines such as PostgreSQL and SQLite,
222             it is time to stop copying logic like this between projects and make public
223             my way to apply database migrations defined in code in a extensible way.
224              
225             It is only possible to migrate forward so be careful.
226              
227             To check an example project that uses this code you can check L
228              
229             =head1 SUBS TO IMPLEMENT IN YOUR OWN DATABASE WRAPPER
230              
231             =head2 migrations
232              
233             sub migrations {
234             return (
235             'CREATE TABLE options (
236             id BIGSERIAL PRIMARY KEY,
237             name TEXT,
238             value TEXT,
239             UNIQUE (name)
240             )',
241             'CREATE TABLE users (
242             id BIGSERIAL PRIMARY KEY,
243             uuid TEXT NOT NULL,
244             username TEXT NOT NULL,
245             name TEXT NOT NULL,
246             surname TEXT NOT NULL,
247             UNIQUE(username)
248             )',
249             );
250             }
251              
252             Returns a list of migrations, creating a options table in the first migration is
253             obligatory since it is internally used to keep track of the current migration number.
254              
255             =head2 dsn
256              
257             sub dsn {
258             return 'dbi:Pg:dbname=my_fancy_app_db';
259             }
260              
261             Returns a valid DSN for L, you can use any logic to return this, even reading a database config file.
262              
263             =head2 user
264              
265             sub user { 'mydbuser' }
266              
267             Returns a valid user for L, you can use any logic to return this, even reading a database config file.
268              
269             =head2 pass
270              
271             sub pass { 'mypass' }
272              
273             Returns a valid password for L, you can use any logic to return this, even reading a database config file.
274              
275             =head2 extra
276              
277             sub extra {
278             {
279             PrintError => 1,
280             }
281             }
282              
283             You can optionally implement this method to pass extra options to L, the
284             return must be a hashref or undef.
285              
286             =head1 FINALIZING THE DATABASE WRAPPER CLASS
287              
288             finish_auto_migrate();
289              
290             Calling this method will ensure your class is completely ready to be used,
291             you can do it at any point if every prerequisite is available.
292              
293             =head1 METHODS AUTOMATICALLY AVAILABLE IN YOUR WRAPPER
294              
295             =head2 connect
296              
297             my $dbh = MyCompany::DB->connect;
298              
299             Same as L::C but without taking any argument.
300              
301             =head2 connect_cached
302              
303             my $dbh = MyCompany::DB->connect_cached;
304              
305             Same as L::C but without taking any argument.
306              
307             =head1 BUGS AND LIMITATIONS
308              
309             Tries to be database independent, but I cannot really ensure it.
310              
311             More testing is needed.
312              
313             =head1 AUTHOR
314              
315             SERGIOXZ - Sergio Iglesias
316              
317             =head1 CONTRIBUTORS
318              
319             SERGIOXZ - Sergio Iglesias
320              
321             =head1 COPYRIGHT
322              
323             Copyright © Sergio Iglesias (2025)
324              
325             =head1 LICENSE
326              
327             This library is free software and may be distributed under the same terms
328             as perl itself. See L.
329              
330             =cut