• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 17023021568

17 Aug 2025 03:58PM UTC coverage: 84.231% (-0.02%) from 84.246%
17023021568

push

github

web-flow
feat: migrations lock (#9660)

* feat: migrations lock

* cs fix

* update phpstan baseline

* add upgrading notes

* apply suggestions from code review

90 of 151 new or added lines in 2 files covered. (59.6%)

2 existing lines in 1 file now uncovered.

20950 of 24872 relevant lines covered (84.23%)

194.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.05
/system/Database/MigrationRunner.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Database;
15

16
use CodeIgniter\CLI\CLI;
17
use CodeIgniter\Database\Exceptions\DatabaseException;
18
use CodeIgniter\Events\Events;
19
use CodeIgniter\Exceptions\ConfigException;
20
use CodeIgniter\Exceptions\RuntimeException;
21
use CodeIgniter\I18n\Time;
22
use Config\Database;
23
use Config\Migrations as MigrationsConfig;
24
use stdClass;
25

26
/**
27
 * Class MigrationRunner
28
 */
29
class MigrationRunner
30
{
31
    /**
32
     * Whether or not migrations are allowed to run.
33
     *
34
     * @var bool
35
     */
36
    protected $enabled = false;
37

38
    /**
39
     * Name of table to store meta information
40
     *
41
     * @var string
42
     */
43
    protected $table;
44

45
    /**
46
     * The Namespace where migrations can be found.
47
     * `null` is all namespaces.
48
     *
49
     * @var string|null
50
     */
51
    protected $namespace;
52

53
    /**
54
     * The database Group to migrate.
55
     *
56
     * @var string
57
     */
58
    protected $group;
59

60
    /**
61
     * The migration name.
62
     *
63
     * @var string
64
     */
65
    protected $name;
66

67
    /**
68
     * The pattern used to locate migration file versions.
69
     *
70
     * @var string
71
     */
72
    protected $regex = '/\A(\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6})_(\w+)\z/';
73

74
    /**
75
     * The main database connection. Used to store
76
     * migration information in.
77
     *
78
     * @var BaseConnection
79
     */
80
    protected $db;
81

82
    /**
83
     * If true, will continue instead of throwing
84
     * exceptions.
85
     *
86
     * @var bool
87
     */
88
    protected $silent = false;
89

90
    /**
91
     * used to return messages for CLI.
92
     *
93
     * @var array
94
     */
95
    protected $cliMessages = [];
96

97
    /**
98
     * Tracks whether we have already ensured
99
     * the table exists or not.
100
     *
101
     * @var bool
102
     */
103
    protected $tableChecked = false;
104

105
    /**
106
     * Lock the migration table.
107
     */
108
    protected bool $lock = false;
109

110
    /**
111
     * Tracks whether we have already ensured
112
     * the lock table exists or not.
113
     */
114
    protected bool $lockTableChecked = false;
115

116
    /**
117
     * The full path to locate migration files.
118
     *
119
     * @var string
120
     */
121
    protected $path;
122

123
    /**
124
     * The database Group filter.
125
     *
126
     * @var string|null
127
     */
128
    protected $groupFilter;
129

130
    /**
131
     * Used to skip current migration.
132
     *
133
     * @var bool
134
     */
135
    protected $groupSkip = false;
136

137
    /**
138
     * The migration can manage multiple databases. So it should always use the
139
     * default DB group so that it creates the `migrations` table in the default
140
     * DB group. Therefore, passing $db is for testing purposes only.
141
     *
142
     * @param array|ConnectionInterface|string|null $db DB group. For testing purposes only.
143
     *
144
     * @throws ConfigException
145
     */
146
    public function __construct(MigrationsConfig $config, $db = null)
147
    {
148
        $this->enabled = $config->enabled ?? false;
720✔
149
        $this->table   = $config->table ?? 'migrations';
720✔
150
        $this->lock    = $config->lock ?? false;
720✔
151

152
        $this->namespace = APP_NAMESPACE;
720✔
153

154
        // Even if a DB connection is passed, since it is a test,
155
        // it is assumed to use the default group name
156
        $this->group = is_string($db) ? $db : config(Database::class)->defaultGroup;
720✔
157

158
        $this->db = db_connect($db);
720✔
159
    }
160

161
    /**
162
     * Locate and run all new migrations
163
     *
164
     * @return bool
165
     *
166
     * @throws ConfigException
167
     * @throws RuntimeException
168
     */
169
    public function latest(?string $group = null)
170
    {
171
        if (! $this->enabled) {
656✔
172
            throw ConfigException::forDisabledMigrations();
1✔
173
        }
174

175
        $this->ensureTable();
655✔
176

177
        // Try to acquire lock - exit gracefully if another process is running migrations
178
        if ($this->lock && ! $this->acquireMigrationLock()) {
655✔
NEW
179
            $message             = lang('Migrations.locked');
×
NEW
180
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');
×
181

UNCOV
182
            return true;
×
183
        }
184

185
        try {
186
            if ($group !== null) {
655✔
187
                $this->groupFilter = $group;
638✔
188
                $this->setGroup($group);
638✔
189
            }
190

191
            $migrations = $this->findMigrations();
655✔
192

193
            if ($migrations === []) {
655✔
NEW
194
                return true;
×
195
            }
196

197
            foreach ($this->getHistory((string) $group) as $history) {
655✔
198
                unset($migrations[$this->getObjectUid($history)]);
12✔
199
            }
200

201
            $batch = $this->getLastBatch() + 1;
655✔
202

203
            foreach ($migrations as $migration) {
655✔
204
                if ($this->migrate('up', $migration)) {
644✔
205
                    if ($this->groupSkip === true) {
644✔
NEW
206
                        $this->groupSkip = false;
×
207

NEW
208
                        continue;
×
209
                    }
210

211
                    $this->addHistory($migration, $batch);
644✔
212
                } else {
NEW
213
                    $this->regress(-1);
×
214

NEW
215
                    $message = lang('Migrations.generalFault');
×
216

NEW
217
                    if ($this->silent) {
×
NEW
218
                        $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
219

NEW
220
                        return false;
×
221
                    }
222

NEW
223
                    throw new RuntimeException($message);
×
224
                }
225
            }
226

227
            $data           = get_object_vars($this);
655✔
228
            $data['method'] = 'latest';
655✔
229
            Events::trigger('migrate', $data);
655✔
230

231
            return true;
655✔
232
        } finally {
233
            if ($this->lock) {
655✔
234
                $this->releaseMigrationLock();
655✔
235
            }
236
        }
237
    }
238

239
    /**
240
     * Migrate down to a previous batch
241
     *
242
     * Calls each migration step required to get to the provided batch
243
     *
244
     * @param int         $targetBatch Target batch number, or negative for a relative batch, 0 for all
245
     * @param string|null $group       Deprecated. The designation has no effect.
246
     *
247
     * @return bool True on success, FALSE on failure or no migrations are found
248
     *
249
     * @throws ConfigException
250
     * @throws RuntimeException
251
     */
252
    public function regress(int $targetBatch = 0, ?string $group = null)
253
    {
254
        if (! $this->enabled) {
663✔
255
            throw ConfigException::forDisabledMigrations();
×
256
        }
257

258
        $this->ensureTable();
663✔
259

260
        // Try to acquire lock - exit gracefully if another process is running migrations
261
        if ($this->lock && ! $this->acquireMigrationLock()) {
663✔
NEW
262
            $message             = lang('Migrations.locked');
×
NEW
263
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');
×
264

UNCOV
265
            return true;
×
266
        }
267

268
        try {
269
            $batches = $this->getBatches();
663✔
270

271
            if ($targetBatch < 0) {
663✔
272
                $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0;
1✔
273
            }
274

275
            if ($batches === [] && $targetBatch === 0) {
663✔
276
                return true;
33✔
277
            }
278

279
            if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) {
639✔
NEW
280
                $message = lang('Migrations.batchNotFound') . $targetBatch;
×
281

NEW
282
                if ($this->silent) {
×
NEW
283
                    $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
284

NEW
285
                    return false;
×
286
                }
287

NEW
288
                throw new RuntimeException($message);
×
289
            }
290

291
            $tmpNamespace = $this->namespace;
639✔
292

293
            $this->namespace = null;
639✔
294
            $allMigrations   = $this->findMigrations();
639✔
295

296
            $migrations = [];
639✔
297

298
            while ($batch = array_pop($batches)) {
639✔
299
                if ($batch <= $targetBatch) {
639✔
300
                    break;
1✔
301
                }
302

303
                foreach ($this->getBatchHistory($batch, 'desc') as $history) {
639✔
304
                    $uid = $this->getObjectUid($history);
639✔
305

306
                    if (! isset($allMigrations[$uid])) {
639✔
NEW
307
                        $message = lang('Migrations.gap') . ' ' . $history->version;
×
308

NEW
309
                        if ($this->silent) {
×
NEW
310
                            $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
311

NEW
312
                            return false;
×
313
                        }
314

NEW
315
                        throw new RuntimeException($message);
×
316
                    }
317

318
                    $migration          = $allMigrations[$uid];
639✔
319
                    $migration->history = $history;
639✔
320
                    $migrations[]       = $migration;
639✔
321
                }
322
            }
323

324
            foreach ($migrations as $migration) {
639✔
325
                if ($this->migrate('down', $migration)) {
639✔
326
                    $this->removeHistory($migration->history);
639✔
327
                } else {
NEW
328
                    $message = lang('Migrations.generalFault');
×
329

330
                    if ($this->silent) {
×
331
                        $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
332

333
                        return false;
×
334
                    }
335

336
                    throw new RuntimeException($message);
×
337
                }
338
            }
339

340
            $data           = get_object_vars($this);
639✔
341
            $data['method'] = 'regress';
639✔
342
            Events::trigger('migrate', $data);
639✔
343

344
            $this->namespace = $tmpNamespace;
639✔
345

346
            return true;
639✔
347
        } finally {
348
            if ($this->lock) {
663✔
349
                $this->releaseMigrationLock();
663✔
350
            }
351
        }
352
    }
353

354
    /**
355
     * Migrate a single file regardless of order or batches.
356
     * Method "up" or "down" determined by presence in history.
357
     * NOTE: This is not recommended and provided mostly for testing.
358
     *
359
     * @param string $path Full path to a valid migration file
360
     * @param string $path Namespace of the target migration
361
     *
362
     * @return bool
363
     */
364
    public function force(string $path, string $namespace, ?string $group = null)
365
    {
366
        if (! $this->enabled) {
×
367
            throw ConfigException::forDisabledMigrations();
×
368
        }
369

370
        $this->ensureTable();
×
371

372
        // Try to acquire lock - exit gracefully if another process is running migrations
NEW
373
        if ($this->lock && ! $this->acquireMigrationLock()) {
×
NEW
374
            $message             = lang('Migrations.locked');
×
NEW
375
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');
×
376

NEW
377
            return true;
×
378
        }
379

380
        try {
NEW
381
            if ($group !== null) {
×
NEW
382
                $this->groupFilter = $group;
×
NEW
383
                $this->setGroup($group);
×
384
            }
385

NEW
386
            $migration = $this->migrationFromFile($path, $namespace);
×
NEW
387
            if ($migration === false) {
×
NEW
388
                $message = lang('Migrations.notFound');
×
389

NEW
390
                if ($this->silent) {
×
NEW
391
                    $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
392

NEW
393
                    return false;
×
394
                }
395

NEW
396
                throw new RuntimeException($message);
×
397
            }
398

NEW
399
            $method = 'up';
×
NEW
400
            $this->setNamespace($migration->namespace);
×
401

NEW
402
            foreach ($this->getHistory($this->group) as $history) {
×
NEW
403
                if ($this->getObjectUid($history) === $migration->uid) {
×
NEW
404
                    $method             = 'down';
×
NEW
405
                    $migration->history = $history;
×
NEW
406
                    break;
×
407
                }
408
            }
409

NEW
410
            if ($method === 'up') {
×
NEW
411
                $batch = $this->getLastBatch() + 1;
×
412

NEW
413
                if ($this->migrate('up', $migration) && $this->groupSkip === false) {
×
NEW
414
                    $this->addHistory($migration, $batch);
×
415

NEW
416
                    return true;
×
417
                }
418

NEW
419
                $this->groupSkip = false;
×
NEW
420
            } elseif ($this->migrate('down', $migration)) {
×
NEW
421
                $this->removeHistory($migration->history);
×
422

423
                return true;
×
424
            }
425

NEW
426
            $message = lang('Migrations.generalFault');
×
427

NEW
428
            if ($this->silent) {
×
NEW
429
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
430

NEW
431
                return false;
×
432
            }
433

NEW
434
            throw new RuntimeException($message);
×
435
        } finally {
NEW
436
            if ($this->lock) {
×
NEW
437
                $this->releaseMigrationLock();
×
438
            }
439
        }
440
    }
441

442
    /**
443
     * Retrieves list of available migration scripts
444
     *
445
     * @return array List of all located migrations by their UID
446
     */
447
    public function findMigrations(): array
448
    {
449
        $namespaces = $this->namespace !== null ? [$this->namespace] : array_keys(service('autoloader')->getNamespace());
658✔
450
        $migrations = [];
658✔
451

452
        foreach ($namespaces as $namespace) {
658✔
453
            if (ENVIRONMENT !== 'testing' && $namespace === 'Tests\Support') {
658✔
454
                continue;
×
455
            }
456

457
            foreach ($this->findNamespaceMigrations($namespace) as $migration) {
658✔
458
                $migrations[$migration->uid] = $migration;
657✔
459
            }
460
        }
461

462
        // Sort migrations ascending by their UID (version)
463
        ksort($migrations);
658✔
464

465
        return $migrations;
658✔
466
    }
467

468
    /**
469
     * Retrieves a list of available migration scripts for one namespace
470
     */
471
    public function findNamespaceMigrations(string $namespace): array
472
    {
473
        $migrations = [];
658✔
474
        $locator    = service('locator', true);
658✔
475

476
        if (! empty($this->path)) {
658✔
477
            helper('filesystem');
×
478
            $dir   = rtrim($this->path, DIRECTORY_SEPARATOR) . '/';
×
479
            $files = get_filenames($dir, true, false, false);
×
480
        } else {
481
            $files = $locator->listNamespaceFiles($namespace, '/Database/Migrations/');
658✔
482
        }
483

484
        foreach ($files as $file) {
658✔
485
            $file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file);
657✔
486

487
            if ($migration = $this->migrationFromFile($file, $namespace)) {
657✔
488
                $migrations[] = $migration;
657✔
489
            }
490
        }
491

492
        return $migrations;
658✔
493
    }
494

495
    /**
496
     * Create a migration object from a file path.
497
     *
498
     * @param string $path Full path to a valid migration file.
499
     *
500
     * @return false|object Returns the migration object, or false on failure
501
     */
502
    protected function migrationFromFile(string $path, string $namespace)
503
    {
504
        if (! str_ends_with($path, '.php')) {
657✔
505
            return false;
16✔
506
        }
507

508
        $filename = basename($path, '.php');
657✔
509

510
        if (preg_match($this->regex, $filename) !== 1) {
657✔
511
            return false;
×
512
        }
513

514
        $locator = service('locator', true);
657✔
515

516
        $migration = new stdClass();
657✔
517

518
        $migration->version   = $this->getMigrationNumber($filename);
657✔
519
        $migration->name      = $this->getMigrationName($filename);
657✔
520
        $migration->path      = $path;
657✔
521
        $migration->class     = $locator->getClassname($path);
657✔
522
        $migration->namespace = $namespace;
657✔
523
        $migration->uid       = $this->getObjectUid($migration);
657✔
524

525
        return $migration;
657✔
526
    }
527

528
    /**
529
     * Allows other scripts to modify on the fly as needed.
530
     *
531
     * @return MigrationRunner
532
     */
533
    public function setNamespace(?string $namespace)
534
    {
535
        $this->namespace = $namespace;
674✔
536

537
        return $this;
674✔
538
    }
539

540
    /**
541
     * Allows other scripts to modify on the fly as needed.
542
     *
543
     * @return MigrationRunner
544
     */
545
    public function setGroup(string $group)
546
    {
547
        $this->group = $group;
639✔
548

549
        return $this;
639✔
550
    }
551

552
    /**
553
     * @return MigrationRunner
554
     */
555
    public function setName(string $name)
556
    {
557
        $this->name = $name;
646✔
558

559
        return $this;
646✔
560
    }
561

562
    /**
563
     * If $silent == true, then will not throw exceptions and will
564
     * attempt to continue gracefully.
565
     *
566
     * @return MigrationRunner
567
     */
568
    public function setSilent(bool $silent)
569
    {
570
        $this->silent = $silent;
717✔
571

572
        return $this;
717✔
573
    }
574

575
    /**
576
     * Extracts the migration number from a filename
577
     *
578
     * @param string $migration A migration filename w/o path.
579
     */
580
    protected function getMigrationNumber(string $migration): string
581
    {
582
        preg_match($this->regex, $migration, $matches);
661✔
583

584
        return $matches !== [] ? $matches[1] : '0';
661✔
585
    }
586

587
    /**
588
     * Extracts the migration name from a filename
589
     *
590
     * Note: The migration name should be the classname, but maybe they are
591
     *       different.
592
     *
593
     * @param string $migration A migration filename w/o path.
594
     */
595
    protected function getMigrationName(string $migration): string
596
    {
597
        preg_match($this->regex, $migration, $matches);
659✔
598

599
        return $matches !== [] ? $matches[2] : '';
659✔
600
    }
601

602
    /**
603
     * Uses the non-repeatable portions of a migration or history
604
     * to create a sortable unique key
605
     *
606
     * @param object $object migration or $history
607
     */
608
    public function getObjectUid($object): string
609
    {
610
        return preg_replace('/[^0-9]/', '', $object->version) . $object->class;
657✔
611
    }
612

613
    /**
614
     * Retrieves messages formatted for CLI output
615
     */
616
    public function getCliMessages(): array
617
    {
618
        return $this->cliMessages;
11✔
619
    }
620

621
    /**
622
     * Clears any CLI messages.
623
     *
624
     * @return MigrationRunner
625
     */
626
    public function clearCliMessages()
627
    {
628
        $this->cliMessages = [];
11✔
629

630
        return $this;
11✔
631
    }
632

633
    /**
634
     * Truncates the history table.
635
     *
636
     * @return void
637
     */
638
    public function clearHistory()
639
    {
640
        if ($this->db->tableExists($this->table)) {
8✔
641
            $this->db->table($this->table)->truncate();
7✔
642
        }
643
    }
644

645
    /**
646
     * Add a history to the table.
647
     *
648
     * @param object $migration
649
     *
650
     * @return void
651
     */
652
    protected function addHistory($migration, int $batch)
653
    {
654
        $this->db->table($this->table)->insert([
644✔
655
            'version'   => $migration->version,
644✔
656
            'class'     => $migration->class,
644✔
657
            'group'     => $this->group,
644✔
658
            'namespace' => $migration->namespace,
644✔
659
            'time'      => Time::now()->getTimestamp(),
644✔
660
            'batch'     => $batch,
644✔
661
        ]);
644✔
662

663
        if (is_cli()) {
644✔
664
            $this->cliMessages[] = sprintf(
644✔
665
                "\t%s(%s) %s_%s",
644✔
666
                CLI::color(lang('Migrations.added'), 'yellow'),
644✔
667
                $migration->namespace,
644✔
668
                $migration->version,
644✔
669
                $migration->class,
644✔
670
            );
644✔
671
        }
672
    }
673

674
    /**
675
     * Removes a single history
676
     *
677
     * @param object $history
678
     *
679
     * @return void
680
     */
681
    protected function removeHistory($history)
682
    {
683
        $this->db->table($this->table)->where('id', $history->id)->delete();
639✔
684

685
        if (is_cli()) {
639✔
686
            $this->cliMessages[] = sprintf(
639✔
687
                "\t%s(%s) %s_%s",
639✔
688
                CLI::color(lang('Migrations.removed'), 'yellow'),
639✔
689
                $history->namespace,
639✔
690
                $history->version,
639✔
691
                $history->class,
639✔
692
            );
639✔
693
        }
694
    }
695

696
    /**
697
     * Grabs the full migration history from the database for a group
698
     */
699
    public function getHistory(string $group = 'default'): array
700
    {
701
        $this->ensureTable();
657✔
702

703
        $builder = $this->db->table($this->table);
657✔
704

705
        // If group was specified then use it
706
        if ($group !== '') {
657✔
707
            $builder->where('group', $group);
643✔
708
        }
709

710
        // If a namespace was specified then use it
711
        if ($this->namespace !== null) {
657✔
712
            $builder->where('namespace', $this->namespace);
652✔
713
        }
714

715
        $query = $builder->orderBy('id', 'ASC')->get();
657✔
716

717
        return ! empty($query) ? $query->getResultObject() : [];
657✔
718
    }
719

720
    /**
721
     * Returns the migration history for a single batch.
722
     *
723
     * @param string $order
724
     */
725
    public function getBatchHistory(int $batch, $order = 'asc'): array
726
    {
727
        $this->ensureTable();
639✔
728

729
        $query = $this->db->table($this->table)
639✔
730
            ->where('batch', $batch)
639✔
731
            ->orderBy('id', $order)
639✔
732
            ->get();
639✔
733

734
        return ! empty($query) ? $query->getResultObject() : [];
639✔
735
    }
736

737
    /**
738
     * Returns all the batches from the database history in order
739
     */
740
    public function getBatches(): array
741
    {
742
        $this->ensureTable();
663✔
743

744
        $batches = $this->db->table($this->table)
663✔
745
            ->select('batch')
663✔
746
            ->distinct()
663✔
747
            ->orderBy('batch', 'asc')
663✔
748
            ->get()
663✔
749
            ->getResultArray();
663✔
750

751
        return array_map(intval(...), array_column($batches, 'batch'));
663✔
752
    }
753

754
    /**
755
     * Returns the value of the last batch in the database.
756
     */
757
    public function getLastBatch(): int
758
    {
759
        $this->ensureTable();
655✔
760

761
        $batch = $this->db->table($this->table)
655✔
762
            ->selectMax('batch')
655✔
763
            ->get()
655✔
764
            ->getResultObject();
655✔
765

766
        $batch = is_array($batch) && $batch !== []
655✔
767
            ? end($batch)->batch
655✔
768
            : 0;
×
769

770
        return (int) $batch;
655✔
771
    }
772

773
    /**
774
     * Returns the version number of the first migration for a batch.
775
     * Mostly just for tests.
776
     */
777
    public function getBatchStart(int $batch): string
778
    {
779
        if ($batch < 0) {
1✔
780
            $batches = $this->getBatches();
×
781
            $batch   = $batches[count($batches) - 1] ?? 0;
×
782
        }
783

784
        $migration = $this->db->table($this->table)
1✔
785
            ->where('batch', $batch)
1✔
786
            ->orderBy('id', 'asc')
1✔
787
            ->limit(1)
1✔
788
            ->get()
1✔
789
            ->getResultObject();
1✔
790

791
        return $migration !== [] ? $migration[0]->version : '0';
1✔
792
    }
793

794
    /**
795
     * Returns the version number of the last migration for a batch.
796
     * Mostly just for tests.
797
     */
798
    public function getBatchEnd(int $batch): string
799
    {
800
        if ($batch < 0) {
5✔
801
            $batches = $this->getBatches();
×
802
            $batch   = $batches[count($batches) - 1] ?? 0;
×
803
        }
804

805
        $migration = $this->db->table($this->table)
5✔
806
            ->where('batch', $batch)
5✔
807
            ->orderBy('id', 'desc')
5✔
808
            ->limit(1)
5✔
809
            ->get()
5✔
810
            ->getResultObject();
5✔
811

812
        return $migration === [] ? '0' : $migration[0]->version;
5✔
813
    }
814

815
    /**
816
     * Ensures that we have created our migrations table
817
     * in the database.
818
     *
819
     * @return void
820
     */
821
    public function ensureTable()
822
    {
823
        if ($this->tableChecked || $this->db->tableExists($this->table)) {
674✔
824
            return;
674✔
825
        }
826

827
        $forge = Database::forge($this->db);
4✔
828

829
        $forge->addField([
4✔
830
            'id' => [
4✔
831
                'type'           => 'BIGINT',
4✔
832
                'constraint'     => 20,
4✔
833
                'unsigned'       => true,
4✔
834
                'auto_increment' => true,
4✔
835
            ],
4✔
836
            'version' => [
4✔
837
                'type'       => 'VARCHAR',
4✔
838
                'constraint' => 255,
4✔
839
                'null'       => false,
4✔
840
            ],
4✔
841
            'class' => [
4✔
842
                'type'       => 'VARCHAR',
4✔
843
                'constraint' => 255,
4✔
844
                'null'       => false,
4✔
845
            ],
4✔
846
            'group' => [
4✔
847
                'type'       => 'VARCHAR',
4✔
848
                'constraint' => 255,
4✔
849
                'null'       => false,
4✔
850
            ],
4✔
851
            'namespace' => [
4✔
852
                'type'       => 'VARCHAR',
4✔
853
                'constraint' => 255,
4✔
854
                'null'       => false,
4✔
855
            ],
4✔
856
            'time' => [
4✔
857
                'type'       => 'INT',
4✔
858
                'constraint' => 11,
4✔
859
                'null'       => false,
4✔
860
            ],
4✔
861
            'batch' => [
4✔
862
                'type'       => 'INT',
4✔
863
                'constraint' => 11,
4✔
864
                'unsigned'   => true,
4✔
865
                'null'       => false,
4✔
866
            ],
4✔
867
        ]);
4✔
868

869
        $forge->addPrimaryKey('id');
4✔
870
        $forge->createTable($this->table, true);
4✔
871

872
        $this->tableChecked = true;
4✔
873
    }
874

875
    /**
876
     * Ensures that we have created our migration
877
     * lock table in the database.
878
     *
879
     * @return string The lock table name
880
     */
881
    protected function ensureLockTable(): string
882
    {
883
        $lockTable = $this->table . '_lock';
4✔
884

885
        if ($this->lockTableChecked || $this->db->tableExists($lockTable)) {
4✔
886
            $this->lockTableChecked = true;
4✔
887

888
            return $lockTable;
4✔
889
        }
890

891
        $forge = Database::forge($this->db);
1✔
892

893
        $forge->addField([
1✔
894
            'id' => [
1✔
895
                'type'           => 'BIGINT',
1✔
896
                'auto_increment' => true,
1✔
897
            ],
1✔
898
            'lock_name' => [
1✔
899
                'type'       => 'VARCHAR',
1✔
900
                'constraint' => 255,
1✔
901
                'null'       => false,
1✔
902
                'unique'     => true,
1✔
903
            ],
1✔
904
            'acquired_at' => [
1✔
905
                'type' => 'INTEGER',
1✔
906
                'null' => false,
1✔
907
            ],
1✔
908
        ]);
1✔
909

910
        $forge->addPrimaryKey('id');
1✔
911
        $forge->createTable($lockTable, true);
1✔
912

913
        $this->lockTableChecked = true;
1✔
914

915
        return $lockTable;
1✔
916
    }
917

918
    /**
919
     * Acquire exclusive lock on migrations to prevent concurrent execution
920
     *
921
     * @return bool True if lock was acquired, false if another process holds the lock
922
     */
923
    protected function acquireMigrationLock(): bool
924
    {
925
        $lockTable = $this->ensureLockTable();
4✔
926

927
        try {
928
            $this->db->table($lockTable)->insert([
4✔
929
                'lock_name'   => 'migration_process',
4✔
930
                'acquired_at' => Time::now()->getTimestamp(),
4✔
931
            ]);
4✔
932

933
            return $this->db->insertID() > 0;
4✔
934
        } catch (DatabaseException) {
1✔
935
            // Lock already exists or other error
936
            return false;
1✔
937
        }
938
    }
939

940
    /**
941
     * Release migration lock
942
     *
943
     * @return bool True if successfully released, false on error
944
     */
945
    protected function releaseMigrationLock(): bool
946
    {
947
        $lockTable = $this->ensureLockTable();
4✔
948

949
        $result = $this->db->table($lockTable)
4✔
950
            ->where('lock_name', 'migration_process')
4✔
951
            ->delete();
4✔
952

953
        if ($result === false) {
4✔
NEW
954
            log_message('warning', 'Failed to release migration lock');
×
955
        }
956

957
        return $result;
4✔
958
    }
959

960
    /**
961
     * Handles the actual running of a migration.
962
     *
963
     * @param string $direction "up" or "down"
964
     * @param object $migration The migration to run
965
     */
966
    protected function migrate($direction, $migration): bool
967
    {
968
        include_once $migration->path;
645✔
969

970
        $class = $migration->class;
645✔
971
        $this->setName($migration->name);
645✔
972

973
        // Validate the migration file structure
974
        if (! class_exists($class, false)) {
645✔
975
            $message = sprintf(lang('Migrations.classNotFound'), $class);
×
976

977
            if ($this->silent) {
×
978
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
979

980
                return false;
×
981
            }
982

983
            throw new RuntimeException($message);
×
984
        }
985

986
        /** @var Migration $instance */
987
        $instance = new $class(Database::forge($this->db));
645✔
988
        $group    = $instance->getDBGroup() ?? $this->group;
645✔
989

990
        if (ENVIRONMENT !== 'testing' && $group === 'tests' && $this->groupFilter !== 'tests') {
645✔
991
            // @codeCoverageIgnoreStart
992
            $this->groupSkip = true;
×
993

994
            return true;
×
995
            // @codeCoverageIgnoreEnd
996
        }
997

998
        if ($direction === 'up' && $this->groupFilter !== null && $this->groupFilter !== $group) {
645✔
999
            $this->groupSkip = true;
×
1000

1001
            return true;
×
1002
        }
1003

1004
        if (! is_callable([$instance, $direction])) {
645✔
1005
            $message = sprintf(lang('Migrations.missingMethod'), $direction);
×
1006

1007
            if ($this->silent) {
×
1008
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');
×
1009

1010
                return false;
×
1011
            }
1012

1013
            throw new RuntimeException($message);
×
1014
        }
1015

1016
        $instance->{$direction}();
645✔
1017

1018
        return true;
645✔
1019
    }
1020
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc