Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
Db
n/a
0 / 0
n/a
0 / 0
39
n/a
0 / 0
 startExecuteSqlFile
n/a
0 / 0
n/a
0 / 0
7
 executeSql
n/a
0 / 0
n/a
0 / 0
1
 startUsingDatabase
n/a
0 / 0
n/a
0 / 0
3
 startTestDataImport
n/a
0 / 0
n/a
0 / 0
1
 startConfigDataImport
n/a
0 / 0
n/a
0 / 0
1
 startMigrations
n/a
0 / 0
n/a
0 / 0
4
 executeTestData
n/a
0 / 0
n/a
0 / 0
2
 openSqlFileHandle
n/a
0 / 0
n/a
0 / 0
2
 readDelimiterDirective
n/a
0 / 0
n/a
0 / 0
2
 lineEndsWithStatementDelimiter
n/a
0 / 0
n/a
0 / 0
2
 filterMigrationFilesByPhase
n/a
0 / 0
n/a
0 / 0
5
 extractSqlStatement
n/a
0 / 0
n/a
0 / 0
2
 executeSqlStatement
n/a
0 / 0
n/a
0 / 0
3
 applyDatabaseNameToConnection
n/a
0 / 0
n/a
0 / 0
2
 resolveMigrationFileList
n/a
0 / 0
n/a
0 / 0
2
1<?php
2
3namespace BO\Zmsbackend\Cli;
4
5/**
6 * @codeCoverageIgnore
7 * @SuppressWarnings(Short)
8 */
9class Db
10{
11    public static $baseDSN = '';
12
13    public static function startExecuteSqlFile(string $file, $databaseName = null, bool $verbose = true): void
14    {
15        $databaseConnection = self::startUsingDatabase($databaseName, $verbose);
16        $startedAt = microtime(true);
17        $sqlFileHandle = self::openSqlFileHandle($file);
18
19        if ($verbose) {
20            \App::$log->info('Importing SQL file', ['file' => basename($file)]);
21        }
22
23        $statementDelimiter = ';';
24        $statementBuffer = '';
25
26        while ($line = $sqlFileHandle['readLine']($sqlFileHandle['handle'])) {
27            $delimiterFromLine = self::readDelimiterDirective($line);
28            if ($delimiterFromLine !== null) {
29                $statementDelimiter = $delimiterFromLine;
30                continue;
31            }
32
33            $statementBuffer .= $line;
34            if (!self::lineEndsWithStatementDelimiter($line, $statementDelimiter)) {
35                continue;
36            }
37
38            $sqlStatement = self::extractSqlStatement($statementBuffer, $statementDelimiter);
39            $statementBuffer = '';
40
41            if ($sqlStatement === '') {
42                continue;
43            }
44
45            self::executeSqlStatement($databaseConnection, $sqlStatement, $file, $verbose);
46        }
47
48        $sqlFileHandle['close']($sqlFileHandle['handle']);
49
50        if ($verbose) {
51            \App::$log->info('SQL import finished', [
52                'file' => basename($file),
53                'seconds' => round(microtime(true) - $startedAt, 3),
54            ]);
55        }
56    }
57
58    /**
59     * @psalm-api
60     */
61    public static function executeSql($query, $databaseName = null): void
62    {
63        $databaseConnection = self::startUsingDatabase($databaseName, false);
64        $databaseConnection->exec($query);
65    }
66
67    /**
68     * @param null|string $databaseName
69     *
70     */
71    public static function startUsingDatabase(string|null $databaseName = null, bool $verbose = true): \BO\Zmsbackend\Connection\Pdo
72    {
73        if (!self::$baseDSN) {
74            self::$baseDSN = \BO\Zmsbackend\Connection\Select::$writeSourceName;
75        }
76
77        \BO\Zmsbackend\Connection\Select::closeWriteConnection();
78        self::applyDatabaseNameToConnection($databaseName);
79
80        if ($verbose) {
81            \App::$log->info('Using database connection', [
82                'dsn' => \BO\Zmsbackend\Connection\Select::$writeSourceName,
83            ]);
84        }
85
86        return \BO\Zmsbackend\Connection\Select::getWriteConnection();
87    }
88
89    public static function startTestDataImport($fixturesDirectory, $filename = 'mysql_zmsbo.sql'): void
90    {
91        $defaultDatabaseName = \BO\Zmsbackend\Connection\Select::$dbname_zms;
92
93        $databaseConnection = self::startUsingDatabase('information_schema');
94        $databaseConnection->exec("DROP DATABASE IF EXISTS `$defaultDatabaseName`;");
95        $databaseConnection->exec("CREATE DATABASE IF NOT EXISTS `$defaultDatabaseName`;");
96
97        self::startExecuteSqlFile($fixturesDirectory . '/' . $filename);
98    }
99
100    public static function startConfigDataImport(): void
101    {
102        $defaults = new \BO\Zmsentities\Config();
103        (new \BO\Zmsbackend\Config\Service\Config())->updateEntity($defaults);
104    }
105
106    public static function startMigrations($migrationList, $commit = true, ?string $phase = null): int
107    {
108        $migrationFiles = self::resolveMigrationFileList($migrationList);
109        $migrationFiles = self::filterMigrationFilesByPhase($migrationFiles, $phase);
110        $databaseConnection = self::startUsingDatabase();
111        $completedMigrations = $databaseConnection->fetchPairs(
112            'SELECT filename, changeTimestamp FROM migrations'
113        );
114        $addedMigrationCount = 0;
115
116        foreach ($migrationFiles as $migrationFile) {
117            $migrationFilename = basename($migrationFile);
118            if (array_key_exists($migrationFilename, $completedMigrations)) {
119                continue;
120            }
121
122            $addedMigrationCount++;
123            if (!$commit) {
124                \App::$log->info('Pending migration', [
125                    'index' => $addedMigrationCount,
126                    'migration' => $migrationFilename,
127                ]);
128                continue;
129            }
130
131            self::startExecuteSqlFile($migrationFile);
132            $databaseConnection->prepare('INSERT INTO `migrations` SET `filename` = :filename')
133                ->execute(['filename' => $migrationFilename]);
134        }
135
136        \App::$log->info('Migration check finished', [
137            'completed' => count($completedMigrations),
138            'added' => $addedMigrationCount,
139        ]);
140
141        return $addedMigrationCount;
142    }
143
144    /**
145     * @return void
146     */
147    public static function executeTestData(string $testName, string $step)
148    {
149        $fixturesDirectory = realpath(__DIR__ . '/../../../tests/Zmsbackend/Service/fixtures/');
150        $sqlFile = $fixturesDirectory . '/' . $testName . '/' . $step . '.sql';
151
152        if (!file_exists($sqlFile)) {
153            return;
154        }
155
156        self::startExecuteSqlFile($sqlFile, null, false);
157    }
158
159    private static function openSqlFileHandle(string $file): array
160    {
161        $isGzipCompressed = substr($file, -3) === '.gz';
162
163        if ($isGzipCompressed) {
164            return [
165                'handle' => gzopen($file, 'r'),
166                'readLine' => 'gzgets',
167                'close' => 'gzclose',
168            ];
169        }
170
171        return [
172            'handle' => fopen($file, 'r'),
173            'readLine' => 'fgets',
174            'close' => 'fclose',
175        ];
176    }
177
178    private static function readDelimiterDirective(string $line): ?string
179    {
180        if (!preg_match('/^\s*DELIMITER\s+(\S+)\s*$/i', rtrim($line), $matches)) {
181            return null;
182        }
183
184        return $matches[1];
185    }
186
187    private static function lineEndsWithStatementDelimiter(string $line, string $statementDelimiter): bool
188    {
189        if ($statementDelimiter === ';') {
190            return (bool) preg_match('/;\s*$/', $line);
191        }
192
193        return (bool) preg_match(
194            '/' . preg_quote($statementDelimiter, '/') . '\s*$/',
195            rtrim($line)
196        );
197    }
198
199    /**
200     * Split migrations into expand/contract phases by filename for zero-downtime
201     * (Expand–Contract) deployments.
202     *
203     * - contract phase: files whose name contains "-contract-" or "-contract." (destructive cleanup)
204     * - expand phase:   every other file, i.e. "*-expand-*" plus unprefixed additive migrations
205     * - no phase (null/empty): all files, preserving the legacy behaviour
206     */
207    private static function filterMigrationFilesByPhase(array $migrationFiles, ?string $phase): array
208    {
209        if ($phase === null || $phase === '') {
210            return $migrationFiles;
211        }
212
213        $isContract = static function (string $migrationFile): bool {
214            return (bool) preg_match('/-contract[-.]/', basename($migrationFile));
215        };
216
217        if ($phase === 'contract') {
218            return array_values(array_filter($migrationFiles, $isContract));
219        }
220
221        if ($phase === 'expand') {
222            return array_values(array_filter(
223                $migrationFiles,
224                static fn(string $migrationFile): bool => !$isContract($migrationFile)
225            ));
226        }
227
228        throw new \InvalidArgumentException(
229            "Unknown migration phase '$phase'; expected 'expand' or 'contract'"
230        );
231    }
232
233    private static function extractSqlStatement(string $statementBuffer, string $statementDelimiter): string
234    {
235        $sqlStatement = $statementBuffer;
236
237        if ($statementDelimiter !== ';') {
238            $sqlStatement = preg_replace(
239                '/' . preg_quote($statementDelimiter, '/') . '\s*$/',
240                '',
241                $sqlStatement
242            );
243        }
244
245        return trim($sqlStatement);
246    }
247
248    private static function executeSqlStatement(
249        \BO\Zmsbackend\Connection\Pdo $databaseConnection,
250        string $sqlStatement,
251        string $sourceFile,
252        bool $verbose
253    ): void {
254        try {
255            $databaseConnection->exec($sqlStatement);
256        } catch (\Exception $exception) {
257            if ($verbose) {
258                \App::$log->error('SQL import failed', [
259                    'file' => basename($sourceFile),
260                    'method' => __METHOD__,
261                    'exception' => get_class($exception),
262                    'message' => $exception->getMessage(),
263                    'code' => $exception->getCode(),
264                ]);
265            }
266
267            throw $exception;
268        }
269    }
270
271    private static function applyDatabaseNameToConnection(?string $databaseName): void
272    {
273        if ($databaseName === null) {
274            \BO\Zmsbackend\Connection\Select::$writeSourceName = self::$baseDSN;
275            return;
276        }
277
278        $defaultDatabaseName = \BO\Zmsbackend\Connection\Select::$dbname_zms;
279        \BO\Zmsbackend\Connection\Select::$writeSourceName = preg_replace(
280            "#dbname=$defaultDatabaseName.*?;#",
281            "dbname=$databaseName;",
282            self::$baseDSN
283        );
284    }
285
286    private static function resolveMigrationFileList($migrationList): array
287    {
288        if (!is_array($migrationList)) {
289            $migrationList = glob($migrationList . '/*.sql');
290        }
291
292        sort($migrationList);
293
294        return $migrationList;
295    }
296}