Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
86 / 86
89.06% covered (warning)
89.06%
57 / 64
21.69% covered (danger)
21.69%
18 / 83
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Environment
100.00% covered (success)
100.00%
86 / 86
89.06% covered (warning)
89.06%
57 / 64
21.69% covered (danger)
21.69%
18 / 83
100.00% covered (success)
100.00%
6 / 6
492.56
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getMigrations
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
14 / 14
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
10.50
 normalizeMigrationDirs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 collectMigrations
100.00% covered (success)
100.00%
13 / 13
76.92% covered (warning)
76.92%
10 / 13
10.00% covered (danger)
10.00%
1 / 10
100.00% covered (success)
100.00%
1 / 1
32.24
 checkIfMigrationsTableExists
100.00% covered (success)
100.00%
15 / 15
85.71% covered (warning)
85.71%
12 / 14
16.67% covered (danger)
16.67%
4 / 24
100.00% covered (success)
100.00%
1 / 1
26.83
 getMigrationsTableDDL
100.00% covered (success)
100.00%
27 / 27
87.50% covered (warning)
87.50%
14 / 16
11.11% covered (danger)
11.11%
4 / 36
100.00% covered (success)
100.00%
1 / 1
41.41
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use PDO;
8use RuntimeException;
9
10/**
11 * @api
12 *
13 * @psalm-import-type MigrationDirsNamespaced from Config
14 */
15class Environment
16{
17    public readonly Connection $conn;
18    public readonly string $driver;
19    public readonly string $table;
20    public readonly string $columnMigration;
21    public readonly string $columnApplied;
22    public readonly Database $db;
23
24    /**
25     * @param array<non-empty-string, Connection>|Connection $connections
26     * @param array<array-key, mixed> $options
27     * @param string $connection The key of the connection to use; a single
28     *     connection is `default`
29     */
30    public function __construct(
31        array|Connection $connections,
32        public readonly array $options = [],
33        string $connection = 'default',
34        public readonly bool $showStacktrace = false,
35    ) {
36        $connections = $connections instanceof Connection ? ['default' => $connections] : $connections;
37
38        if (!array_key_exists($connection, $connections)) {
39            throw new RuntimeException("Connection '{$connection}' does not exist");
40        }
41
42        $this->conn = $connections[$connection];
43        $this->db = new Database($this->conn);
44        $this->driver = $this->conn->config->driver;
45        $this->table = $this->conn->config->migrationsTable;
46        $this->columnMigration = $this->conn->config->migrationsColumnMigration;
47        $this->columnApplied = $this->conn->config->migrationsColumnApplied;
48    }
49
50    /**
51     * @return array<string, list<string>>|false
52     */
53    public function getMigrations(): array|false
54    {
55        $migrations = [];
56        $migrationDirs = $this->conn->config->migrations;
57
58        if (count($migrationDirs) === 0) {
59            return false;
60        }
61
62        // Check if migrations is a flat list or namespaced
63        if (array_is_list($migrationDirs)) {
64            // Flat list: wrap in 'default' namespace. Config only keeps
65            // non-empty directories, and an empty list returned above.
66            $migrations['default'] = $this->collectMigrations($this->normalizeMigrationDirs($migrationDirs));
67        } else {
68            // Namespaced: process each namespace. The analyzers cannot narrow
69            // the union by array_is_list().
70            /** @var MigrationDirsNamespaced $namespaced */
71            $namespaced = $migrationDirs;
72
73            foreach ($namespaced as $namespace => $dirs) {
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
75
76                if (count($resolvedDirs) === 0) {
77                    continue;
78                }
79
80                $migrations[$namespace] = $this->collectMigrations($resolvedDirs);
81            }
82        }
83
84        return $migrations;
85    }
86
87    /**
88     * @param array<array-key, mixed> $dirs
89     *
90     * @return list<non-empty-string>
91     */
92    protected function normalizeMigrationDirs(array $dirs): array
93    {
94        $normalized = [];
95
96        array_walk(
97            $dirs,
98            static function (mixed $path) use (&$normalized): void {
99                if (is_string($path) && $path !== '') {
100                    $normalized[] = $path;
101                }
102            },
103        );
104
105        return $normalized;
106    }
107
108    /**
109     * @param list<non-empty-string> $migrationDirs
110     *
111     * @return list<string>
112     */
113    protected function collectMigrations(array $migrationDirs): array
114    {
115        $migrations = [];
116
117        foreach ($migrationDirs as $path) {
118            $phpFiles = glob("{$path}/*.php");
119            $sqlFiles = glob("{$path}/*.sql");
120            $tpqlFiles = glob("{$path}/*.tpql");
121
122            $migrations = array_merge(
123                $migrations,
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
127            );
128        }
129
130        // Sort by file name instead of full path
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
132
133        return array_values($migrations);
134    }
135
136    public function checkIfMigrationsTableExists(Database $db): bool
137    {
138        $driver = $db->getPdoDriver();
139        $table = $this->table;
140
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
142            [$schema, $table] = explode('.', $table);
143        } else {
144            $schema = 'public';
145        }
146
147        $query = match ($driver) {
148            'sqlite' => "
149                SELECT count(*) AS available
150                FROM sqlite_master
151                WHERE type='table'
152                AND name='{$table}';",
153            'mysql' => "
154                SELECT count(*) AS available
155                FROM information_schema.tables
156                WHERE table_schema = DATABASE()
157                AND table_name='{$table}';",
158            'pgsql' => "
159                SELECT count(*) AS available
160                FROM pg_tables
161                WHERE schemaname = '{$schema}'
162                AND tablename = '{$table}';",
163        };
164
165        return ($db->execute($query)->one(fetchMode: PDO::FETCH_ASSOC)['available'] ?? null) === 1;
166    }
167
168    public function getMigrationsTableDDL(): string|false
169    {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
171            [$schema, $table] = explode('.', $this->table);
172        } else {
173            $schema = 'public';
174            $table = $this->table;
175        }
176        $columnMigration = $this->columnMigration;
177        $columnApplied = $this->columnApplied;
178
179        switch ($this->driver) {
180            case 'sqlite':
181                return "CREATE TABLE {$table} (
182    {$columnMigration} text NOT NULL,
183    {$columnApplied} text DEFAULT CURRENT_TIMESTAMP,
184    PRIMARY KEY ({$columnMigration}),
185    CHECK(typeof(\"{$columnMigration}\") = \"text\" AND length(\"{$columnMigration}\") <= 256),
186    CHECK(typeof(\"{$columnApplied}\") = \"text\" AND length(\"{$columnApplied}\") = 19)
187);";
188
189            case 'pgsql':
190                return "CREATE TABLE {$schema}.{$table} (
191    {$columnMigration} text NOT NULL CHECK (char_length({$columnMigration}) <= 256),
192    {$columnApplied} timestamp with time zone DEFAULT now() NOT NULL,
193    CONSTRAINT pk_{$table} PRIMARY KEY ({$columnMigration})
194);";
195
196            case 'mysql':
197                return "CREATE TABLE {$table} (
198    {$columnMigration} varchar(256) NOT NULL,
199    {$columnApplied} timestamp DEFAULT CURRENT_TIMESTAMP,
200    PRIMARY KEY ({$columnMigration})
201);";
202
203            default:
204                // Cannot be reliably tested.
205                // Would require an unsupported driver to be installed.
206                // @codeCoverageIgnoreStart
207                return false;
208
209            // @codeCoverageIgnoreEnd
210        }
211    }
212}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Environment->__construct
31        array|Connection $connections,
32        public readonly array $options = [],
33        string $connection = 'default',
34        public readonly bool $showStacktrace = false,
35    ) {
36        $connections = $connections instanceof Connection ? ['default' => $connections] : $connections;
36        $connections = $connections instanceof Connection ? ['default' => $connections] : $connections;
36        $connections = $connections instanceof Connection ? ['default' => $connections] : $connections;
36        $connections = $connections instanceof Connection ? ['default' => $connections] : $connections;
37
38        if (!array_key_exists($connection, $connections)) {
39            throw new RuntimeException("Connection '{$connection}' does not exist");
42        $this->conn = $connections[$connection];
43        $this->db = new Database($this->conn);
44        $this->driver = $this->conn->config->driver;
45        $this->table = $this->conn->config->migrationsTable;
46        $this->columnMigration = $this->conn->config->migrationsColumnMigration;
47        $this->columnApplied = $this->conn->config->migrationsColumnApplied;
48    }
Environment->checkIfMigrationsTableExists
136    public function checkIfMigrationsTableExists(Database $db): bool
137    {
138        $driver = $db->getPdoDriver();
139        $table = $this->table;
140
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
141        if ($driver === 'pgsql' && str_contains($table, '.')) {
142            [$schema, $table] = explode('.', $table);
144            $schema = 'public';
145        }
146
147        $query = match ($driver) {
147        $query = match ($driver) {
147        $query = match ($driver) {
148            'sqlite' => "
149                SELECT count(*) AS available
150                FROM sqlite_master
151                WHERE type='table'
152                AND name='{$table}';",
153            'mysql' => "
154                SELECT count(*) AS available
155                FROM information_schema.tables
156                WHERE table_schema = DATABASE()
157                AND table_name='{$table}';",
158            'pgsql' => "
159                SELECT count(*) AS available
160                FROM pg_tables
161                WHERE schemaname = '{$schema}'
162                AND tablename = '{$table}';",
147        $query = match ($driver) {
148            'sqlite' => "
149                SELECT count(*) AS available
150                FROM sqlite_master
151                WHERE type='table'
152                AND name='{$table}';",
153            'mysql' => "
154                SELECT count(*) AS available
155                FROM information_schema.tables
156                WHERE table_schema = DATABASE()
157                AND table_name='{$table}';",
158            'pgsql' => "
159                SELECT count(*) AS available
160                FROM pg_tables
161                WHERE schemaname = '{$schema}'
162                AND tablename = '{$table}';",
163        };
164
165        return ($db->execute($query)->one(fetchMode: PDO::FETCH_ASSOC)['available'] ?? null) === 1;
166    }
Environment->collectMigrations
113    protected function collectMigrations(array $migrationDirs): array
114    {
115        $migrations = [];
116
117        foreach ($migrationDirs as $path) {
117        foreach ($migrationDirs as $path) {
118            $phpFiles = glob("{$path}/*.php");
119            $sqlFiles = glob("{$path}/*.sql");
120            $tpqlFiles = glob("{$path}/*.tpql");
121
122            $migrations = array_merge(
123                $migrations,
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
117        foreach ($migrationDirs as $path) {
118            $phpFiles = glob("{$path}/*.php");
119            $sqlFiles = glob("{$path}/*.sql");
120            $tpqlFiles = glob("{$path}/*.tpql");
121
122            $migrations = array_merge(
123                $migrations,
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
117        foreach ($migrationDirs as $path) {
118            $phpFiles = glob("{$path}/*.php");
119            $sqlFiles = glob("{$path}/*.sql");
120            $tpqlFiles = glob("{$path}/*.tpql");
121
122            $migrations = array_merge(
123                $migrations,
124                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
125                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
126                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
127            );
128        }
129
130        // Sort by file name instead of full path
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
132
133        return array_values($migrations);
134    }
Environment->getMigrations
55        $migrations = [];
56        $migrationDirs = $this->conn->config->migrations;
57
58        if (count($migrationDirs) === 0) {
59            return false;
63        if (array_is_list($migrationDirs)) {
63        if (array_is_list($migrationDirs)) {
64            // Flat list: wrap in 'default' namespace. Config only keeps
65            // non-empty directories, and an empty list returned above.
66            $migrations['default'] = $this->collectMigrations($this->normalizeMigrationDirs($migrationDirs));
71            $namespaced = $migrationDirs;
72
73            foreach ($namespaced as $namespace => $dirs) {
73            foreach ($namespaced as $namespace => $dirs) {
73            foreach ($namespaced as $namespace => $dirs) {
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
75
76                if (count($resolvedDirs) === 0) {
77                    continue;
73            foreach ($namespaced as $namespace => $dirs) {
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
75
76                if (count($resolvedDirs) === 0) {
77                    continue;
78                }
79
80                $migrations[$namespace] = $this->collectMigrations($resolvedDirs);
73            foreach ($namespaced as $namespace => $dirs) {
74                $resolvedDirs = $this->normalizeMigrationDirs(is_string($dirs) ? [$dirs] : $dirs);
75
76                if (count($resolvedDirs) === 0) {
77                    continue;
78                }
79
80                $migrations[$namespace] = $this->collectMigrations($resolvedDirs);
81            }
82        }
83
84        return $migrations;
84        return $migrations;
85    }
Environment->getMigrationsTableDDL
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
170        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
171            [$schema, $table] = explode('.', $this->table);
173            $schema = 'public';
174            $table = $this->table;
175        }
176        $columnMigration = $this->columnMigration;
176        $columnMigration = $this->columnMigration;
177        $columnApplied = $this->columnApplied;
178
179        switch ($this->driver) {
180            case 'sqlite':
189            case 'pgsql':
196            case 'mysql':
196            case 'mysql':
181                return "CREATE TABLE {$table} (
182    {$columnMigration} text NOT NULL,
183    {$columnApplied} text DEFAULT CURRENT_TIMESTAMP,
184    PRIMARY KEY ({$columnMigration}),
185    CHECK(typeof(\"{$columnMigration}\") = \"text\" AND length(\"{$columnMigration}\") <= 256),
186    CHECK(typeof(\"{$columnApplied}\") = \"text\" AND length(\"{$columnApplied}\") = 19)
190                return "CREATE TABLE {$schema}.{$table} (
191    {$columnMigration} text NOT NULL CHECK (char_length({$columnMigration}) <= 256),
192    {$columnApplied} timestamp with time zone DEFAULT now() NOT NULL,
193    CONSTRAINT pk_{$table} PRIMARY KEY ({$columnMigration})
197                return "CREATE TABLE {$table} (
198    {$columnMigration} varchar(256) NOT NULL,
199    {$columnApplied} timestamp DEFAULT CURRENT_TIMESTAMP,
200    PRIMARY KEY ({$columnMigration})
Environment->normalizeMigrationDirs
92    protected function normalizeMigrationDirs(array $dirs): array
93    {
94        $normalized = [];
95
96        array_walk(
97            $dirs,
98            static function (mixed $path) use (&$normalized): void {
99                if (is_string($path) && $path !== '') {
100                    $normalized[] = $path;
101                }
102            },
103        );
104
105        return $normalized;
106    }
{closure:/workspace/celema/quma/src/Environment.php:131-131}
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
131        uasort($migrations, static fn(string $a, string $b): int => basename($a) < basename($b) ? -1 : 1);
{closure:/workspace/celema/quma/src/Environment.php:98-102}
98            static function (mixed $path) use (&$normalized): void {
99                if (is_string($path) && $path !== '') {
99                if (is_string($path) && $path !== '') {
99                if (is_string($path) && $path !== '') {
100                    $normalized[] = $path;
101                }
102            },
102            },