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