Passed
Pull Request — master (#6)
by Damien
03:23
created

SchemaManager::createAuditTable()   B

Complexity

Conditions 10
Paths 4

Size

Total Lines 55
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 31
c 0
b 0
f 0
dl 0
loc 55
rs 7.6666
cc 10
nc 4
nop 3

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace DH\Auditor\Provider\Doctrine\Persistence\Schema;
4
5
use DH\Auditor\Provider\Doctrine\Configuration;
6
use DH\Auditor\Provider\Doctrine\DoctrineProvider;
7
use DH\Auditor\Provider\Doctrine\Persistence\Helper\DoctrineHelper;
8
use DH\Auditor\Provider\Doctrine\Persistence\Helper\SchemaHelper;
9
use DH\Auditor\Provider\Doctrine\Service\AuditingService;
10
use DH\Auditor\Provider\Doctrine\Service\StorageService;
11
use Doctrine\DBAL\Connection;
12
use Doctrine\DBAL\Driver\ServerInfoAwareConnection;
13
use Doctrine\DBAL\Schema\Schema;
14
use Doctrine\DBAL\Schema\SchemaException;
15
use Doctrine\DBAL\Schema\Table;
16
use Doctrine\ORM\EntityManagerInterface;
17
18
class SchemaManager
19
{
20
    /**
21
     * @var DoctrineProvider
22
     */
23
    private $provider;
24
25
    public function __construct(DoctrineProvider $provider)
26
    {
27
        $this->provider = $provider;
28
    }
29
30
    public function updateAuditSchema(?array $sqls = null, ?callable $callback = null): void
31
    {
32
        // TODO: FIXME will create the same schema on all connections
33
        if (null === $sqls) {
34
            $sqls = $this->getUpdateAuditSchemaSql();
35
        }
36
37
        /** @var StorageService[] $storageServices */
38
        $storageServices = $this->provider->getStorageServices();
39
        foreach ($sqls as $name => $queries) {
40
            foreach ($queries as $index => $sql) {
41
                $statement = $storageServices[$name]->getEntityManager()->getConnection()->prepare($sql);
42
                $statement->execute();
43
44
                if (null !== $callback) {
45
                    $callback([
46
                        'total' => \count($sqls),
47
                        'current' => $index,
48
                    ]);
49
                }
50
            }
51
        }
52
    }
53
54
    /**
55
     * Returns an array of audit table names indexed by entity FQN.
56
     * Only auditable entities are considered.
57
     *
58
     * @throws \Doctrine\ORM\ORMException
59
     */
60
    public function getAuditableTableNames(EntityManagerInterface $entityManager): array
61
    {
62
        $metadataDriver = $entityManager->getConfiguration()->getMetadataDriverImpl();
63
        $entities = [];
64
        if (null !== $metadataDriver) {
65
            $entities = $metadataDriver->getAllClassNames();
66
        }
67
        $audited = [];
68
        foreach ($entities as $entity) {
69
            if ($this->provider->isAuditable($entity)) {
70
                $audited[$entity] = $entityManager->getClassMetadata($entity)->getTableName();
71
            }
72
        }
73
        ksort($audited);
74
75
        return $audited;
76
    }
77
78
    public function getUpdateAuditSchemaSql(): array
79
    {
80
        /** @var Configuration $configuration */
81
        $configuration = $this->provider->getConfiguration();
82
83
        /** @var StorageService[] $storageServices */
84
        $storageServices = $this->provider->getStorageServices();
85
86
        // auditable entities by storage entity manager
87
        $repository = [];
88
89
        // Collect auditable entities from auditing storage managers
90
        /** @var AuditingService[] $auditingServices */
91
        $auditingServices = $this->provider->getAuditingServices();
92
        foreach ($auditingServices as $name => $auditingService) {
93
            $classes = $this->getAuditableTableNames($auditingService->getEntityManager());
94
            // Populate the auditable entities repository
95
            foreach ($classes as $entity => $tableName) {
96
                $storageService = $this->provider->getStorageServiceForEntity($entity);
97
                $key = array_search($storageService, $this->provider->getStorageServices(), true);
98
                if (!isset($repository[$key])) {
99
                    $repository[$key] = [];
100
                }
101
                $repository[$key][$entity] = $tableName;
102
            }
103
        }
104
        $findEntityByTablename = static function (string $tableName) use ($repository): ?string {
105
            foreach ($repository as $emName => $map) {
106
                $result = array_search($tableName, $map, true);
107
                if (false !== $result) {
108
                    return (string) $result;
109
                }
110
            }
111
112
            return null;
113
        };
114
115
        // Compute and collect SQL queries
116
        $sqls = [];
117
        foreach ($repository as $name => $classes) {
118
            $storageSchemaManager = $storageServices[$name]->getEntityManager()->getConnection()->getSchemaManager();
119
120
            $storageSchema = $storageSchemaManager->createSchema();
121
            $fromSchema = clone $storageSchema;
122
            $tables = $storageSchema->getTables();
123
            foreach ($tables as $table) {
124
                if (\in_array($table->getName(), array_values($repository[$name]), true)) {
125
                    // table is the one of an auditable entity
126
127
                    /** @var string $auditTablename */
128
                    $auditTablename = preg_replace(
129
                        sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($table->getName(), '#')),
130
                        sprintf(
131
                            '$1%s$2%s',
132
                            preg_quote($configuration->getTablePrefix(), '#'),
133
                            preg_quote($configuration->getTableSuffix(), '#')
134
                        ),
135
                        $table->getName()
136
                    );
137
                    if ($storageSchema->hasTable($auditTablename)) {
138
                        // Audit table exists, let's update it if needed
139
                        $this->updateAuditTable($findEntityByTablename($table->getName()), $storageSchema->getTable($auditTablename), $storageSchema);
140
                    } else {
141
                        // Audit table does not exists, let's create it
142
                        $this->createAuditTable($findEntityByTablename($table->getName()), $table, $storageSchema);
143
                    }
144
                }
145
            }
146
147
            $sqls[$name] = $fromSchema->getMigrateToSql($storageSchema, $storageSchemaManager->getDatabasePlatform());
148
        }
149
150
        return $sqls;
151
    }
152
153
    /**
154
     * Creates an audit table.
155
     */
156
    public function createAuditTable(string $entity, Table $table, ?Schema $schema = null): Schema
157
    {
158
        /** @var StorageService $storageService */
159
        $storageService = $this->provider->getStorageServiceForEntity($entity);
160
        $connection = $storageService->getEntityManager()->getConnection();
161
162
        if (null === $schema) {
163
            $schemaManager = $connection->getSchemaManager();
164
            $schema = $schemaManager->createSchema();
165
        }
166
167
        /** @var Configuration $configuration */
168
        $configuration = $this->provider->getConfiguration();
169
170
        $auditTablename = preg_replace(
171
            sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($table->getName(), '#')),
172
            sprintf(
173
                '$1%s$2%s',
174
                preg_quote($configuration->getTablePrefix(), '#'),
175
                preg_quote($configuration->getTableSuffix(), '#')
176
            ),
177
            $table->getName()
178
        );
179
180
        if (null !== $auditTablename && !$schema->hasTable($auditTablename)) {
181
            $auditTable = $schema->createTable($auditTablename);
182
183
            // Add columns to audit table
184
            $isJsonSupported = $this->isJsonSupported($connection);
185
            foreach (SchemaHelper::getAuditTableColumns() as $columnName => $struct) {
186
                if (DoctrineHelper::getDoctrineType('JSON') === $struct['type'] && $isJsonSupported) {
187
                    $type = DoctrineHelper::getDoctrineType('TEXT');
188
                } else {
189
                    $type = $struct['type'];
190
                }
191
192
                $auditTable->addColumn($columnName, $type, $struct['options']);
193
            }
194
195
            // Add indices to audit table
196
            foreach (SchemaHelper::getAuditTableIndices($auditTablename) as $columnName => $struct) {
197
                if ('primary' === $struct['type']) {
198
                    $auditTable->setPrimaryKey([$columnName]);
199
                } else {
200
                    $auditTable->addIndex(
201
                        [$columnName],
202
                        $struct['name'],
203
                        [],
204
                        $this->isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
205
                    );
206
                }
207
            }
208
        }
209
210
        return $schema;
211
    }
212
213
    /**
214
     * Ensures an audit table's structure is valid.
215
     *
216
     * @throws SchemaException
217
     */
218
    public function updateAuditTable(string $entity, Table $table, ?Schema $schema = null): Schema
219
    {
220
        /** @var StorageService $storageService */
221
        $storageService = $this->provider->getStorageServiceForEntity($entity);
222
        $connection = $storageService->getEntityManager()->getConnection();
223
224
        $schemaManager = $connection->getSchemaManager();
225
        if (null === $schema) {
226
            $schema = $schemaManager->createSchema();
227
        }
228
229
        $table = $schema->getTable($table->getName());
230
        $columns = $schemaManager->listTableColumns($table->getName());
231
232
        // process columns
233
        $this->processColumns($table, $columns, SchemaHelper::getAuditTableColumns(), $connection);
234
235
        // process indices
236
        $this->processIndices($table, SchemaHelper::getAuditTableIndices($table->getName()), $connection);
237
238
        return $schema;
239
    }
240
241
    private function processColumns(Table $table, array $columns, array $expectedColumns, Connection $connection): void
242
    {
243
        $processed = [];
244
245
        $isJsonSupported = $this->isJsonSupported($connection);
246
        foreach ($columns as $column) {
247
            if (\array_key_exists($column->getName(), $expectedColumns)) {
248
                // column is part of expected columns
249
                $table->dropColumn($column->getName());
250
251
                if (DoctrineHelper::getDoctrineType('JSON') === $expectedColumns[$column->getName()]['type'] && $isJsonSupported) {
252
                    $type = DoctrineHelper::getDoctrineType('TEXT');
253
                } else {
254
                    $type = $expectedColumns[$column->getName()]['type'];
255
                }
256
257
                $table->addColumn($column->getName(), $type, $expectedColumns[$column->getName()]['options']);
258
            } else {
259
                // column is not part of expected columns so it has to be removed
260
                $table->dropColumn($column->getName());
261
            }
262
263
            $processed[] = $column->getName();
264
        }
265
266
        foreach ($expectedColumns as $columnName => $options) {
267
            if (!\in_array($columnName, $processed, true)) {
268
                // expected column in not part of concrete ones so it's a new column, we need to add it
269
                if (DoctrineHelper::getDoctrineType('JSON') === $options['type'] && $isJsonSupported) {
270
                    $type = DoctrineHelper::getDoctrineType('TEXT');
0 ignored issues
show
Unused Code introduced by
The assignment to $type is dead and can be removed.
Loading history...
271
                } else {
272
                    $type = $options['type'];
273
                }
274
275
                $table->addColumn($columnName, $options['type'], $options['options']);
276
            }
277
        }
278
    }
279
280
    /**
281
     * @throws SchemaException
282
     */
283
    private function processIndices(Table $table, array $expectedIndices, Connection $connection): void
284
    {
285
        foreach ($expectedIndices as $columnName => $options) {
286
            if ('primary' === $options['type']) {
287
                $table->dropPrimaryKey();
288
                $table->setPrimaryKey([$columnName]);
289
            } else {
290
                if ($table->hasIndex($options['name'])) {
291
                    $table->dropIndex($options['name']);
292
                }
293
                $table->addIndex(
294
                    [$columnName],
295
                    $options['name'],
296
                    [],
297
                    $this->isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
298
                );
299
            }
300
        }
301
    }
302
303
    /**
304
     * MySQL < 5.7.7 and MariaDb < 10.2.2 index length requirements.
305
     *
306
     * @see https://github.com/doctrine/dbal/issues/3419
307
     */
308
    private function isIndexLengthLimited(string $name, Connection $connection): bool
309
    {
310
        $columns = SchemaHelper::getAuditTableColumns();
311
        if (
312
            !isset($columns[$name])
313
            || $columns[$name]['type'] !== DoctrineHelper::getDoctrineType('STRING')
314
            || (
315
                isset($columns[$name]['options'], $columns[$name]['options']['length'])
316
                && $columns[$name]['options']['length'] < 191
317
            )
318
        ) {
319
            return false;
320
        }
321
322
        $version = $this->getServerVersion($connection);
323
324
        if (null === $version) {
325
            return false;
326
        }
327
328
        $mariadb = false !== mb_stripos($version, 'mariadb');
329
        if ($mariadb && version_compare($this->getMariaDbMysqlVersionNumber($version), '10.2.2', '<')) {
330
            return true;
331
        }
332
333
        if (!$mariadb && version_compare($this->getOracleMysqlVersionNumber($version), '5.7.7', '<')) {
334
            return true;
335
        }
336
337
        return false;
338
    }
339
340
    private function getServerVersion(Connection $connection): ?string
341
    {
342
        $wrappedConnection = $connection->getWrappedConnection();
343
344
        if ($wrappedConnection instanceof ServerInfoAwareConnection) {
345
            return $wrappedConnection->getServerVersion();
346
        }
347
348
        return null;
349
    }
350
351
    private function isJsonSupported(Connection $connection): bool
352
    {
353
        $version = $this->getServerVersion($connection);
354
        if (null === $version) {
355
            return false;
356
        }
357
358
        $mariadb = false !== mb_stripos($version, 'mariadb');
359
        if ($mariadb && version_compare($this->getMariaDbMysqlVersionNumber($version), '10.2.7', '<')) {
360
            // JSON wasn't supported on MariaDB before 10.2.7
361
            // @see https://mariadb.com/kb/en/json-data-type/
362
            return false;
363
        }
364
365
        // Assume JSON is supported
366
        return true;
367
    }
368
369
    /**
370
     * Get a normalized 'version number' from the server string
371
     * returned by Oracle MySQL servers.
372
     *
373
     * @param string $versionString Version string returned by the driver, i.e. '5.7.10'
374
     *
375
     * @copyright Doctrine team
376
     */
377
    private function getOracleMysqlVersionNumber(string $versionString): string
378
    {
379
        preg_match(
380
            '/^(?P<major>\d+)(?:\.(?P<minor>\d+)(?:\.(?P<patch>\d+))?)?/',
381
            $versionString,
382
            $versionParts
383
        );
384
385
        $majorVersion = $versionParts['major'];
386
        $minorVersion = $versionParts['minor'] ?? 0;
387
        $patchVersion = $versionParts['patch'] ?? null;
388
389
        if ('5' === $majorVersion && '7' === $minorVersion && null === $patchVersion) {
390
            $patchVersion = '9';
391
        }
392
393
        return $majorVersion.'.'.$minorVersion.'.'.$patchVersion;
394
    }
395
396
    /**
397
     * Detect MariaDB server version, including hack for some mariadb distributions
398
     * that starts with the prefix '5.5.5-'.
399
     *
400
     * @param string $versionString Version string as returned by mariadb server, i.e. '5.5.5-Mariadb-10.0.8-xenial'
401
     *
402
     * @copyright Doctrine team
403
     */
404
    private function getMariaDbMysqlVersionNumber(string $versionString): string
405
    {
406
        preg_match(
407
            '/^(?:5\.5\.5-)?(mariadb-)?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)/i',
408
            $versionString,
409
            $versionParts
410
        );
411
412
        return $versionParts['major'].'.'.$versionParts['minor'].'.'.$versionParts['patch'];
413
    }
414
}
415