Test Failed
Pull Request — master (#3)
by Damien
03:09
created

SchemaManager::getAuditableTableNames()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 10
dl 0
loc 16
c 0
b 0
f 0
rs 9.9332
cc 4
nc 6
nop 1
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
            foreach (SchemaHelper::getAuditTableColumns() as $columnName => $struct) {
185
                $auditTable->addColumn($columnName, $struct['type'], $struct['options']);
186
            }
187
188
            // Add indices to audit table
189
            foreach (SchemaHelper::getAuditTableIndices($auditTablename) as $columnName => $struct) {
190
                if ('primary' === $struct['type']) {
191
                    $auditTable->setPrimaryKey([$columnName]);
192
                } else {
193
                    $auditTable->addIndex(
194
                        [$columnName],
195
                        $struct['name'],
196
                        [],
197
                        $this->isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
198
                    );
199
                }
200
            }
201
        }
202
203
        return $schema;
204
    }
205
206
    /**
207
     * Ensures an audit table's structure is valid.
208
     *
209
     * @throws SchemaException
210
     */
211
    public function updateAuditTable(string $entity, Table $table, ?Schema $schema = null): Schema
212
    {
213
        /** @var StorageService $storageService */
214
        $storageService = $this->provider->getStorageServiceForEntity($entity);
215
        $connection = $storageService->getEntityManager()->getConnection();
216
217
        $schemaManager = $connection->getSchemaManager();
218
        if (null === $schema) {
219
            $schema = $schemaManager->createSchema();
220
        }
221
222
        $table = $schema->getTable($table->getName());
223
        $columns = $schemaManager->listTableColumns($table->getName());
224
225
        // process columns
226
        $this->processColumns($table, $columns, SchemaHelper::getAuditTableColumns());
227
228
        // process indices
229
        $this->processIndices($table, SchemaHelper::getAuditTableIndices($table->getName()), $connection);
230
231
        return $schema;
232
    }
233
234
    private function processColumns(Table $table, array $columns, array $expectedColumns): void
235
    {
236
        $processed = [];
237
238
        foreach ($columns as $column) {
239
            if (\array_key_exists($column->getName(), $expectedColumns)) {
240
                // column is part of expected columns
241
                $table->dropColumn($column->getName());
242
                $table->addColumn($column->getName(), $expectedColumns[$column->getName()]['type'], $expectedColumns[$column->getName()]['options']);
243
            } else {
244
                // column is not part of expected columns so it has to be removed
245
                $table->dropColumn($column->getName());
246
            }
247
248
            $processed[] = $column->getName();
249
        }
250
251
        foreach ($expectedColumns as $columnName => $options) {
252
            if (!\in_array($columnName, $processed, true)) {
253
                // expected column in not part of concrete ones so it's a new column, we need to add it
254
                $table->addColumn($columnName, $options['type'], $options['options']);
255
            }
256
        }
257
    }
258
259
    /**
260
     * @throws SchemaException
261
     */
262
    private function processIndices(Table $table, array $expectedIndices, Connection $connection): void
263
    {
264
        foreach ($expectedIndices as $columnName => $options) {
265
            if ('primary' === $options['type']) {
266
                $table->dropPrimaryKey();
267
                $table->setPrimaryKey([$columnName]);
268
            } else {
269
                if ($table->hasIndex($options['name'])) {
270
                    $table->dropIndex($options['name']);
271
                }
272
                $table->addIndex(
273
                    [$columnName],
274
                    $options['name'],
275
                    [],
276
                    $this->isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
277
                );
278
            }
279
        }
280
    }
281
282
    /**
283
     * MySQL < 5.7.7 and MariaDb < 10.2.2 index length requirements.
284
     *
285
     * @see https://github.com/doctrine/dbal/issues/3419
286
     */
287
    private function isIndexLengthLimited(string $name, Connection $connection): bool
288
    {
289
        $columns = SchemaHelper::getAuditTableColumns();
290
        if (
291
            !isset($columns[$name])
292
            || $columns[$name]['type'] !== DoctrineHelper::getDoctrineType('STRING')
293
            || (
294
                isset($columns[$name]['options'], $columns[$name]['options']['length'])
295
296
                && $columns[$name]['options']['length'] < 191
297
            )
298
        ) {
299
            return false;
300
        }
301
302
        $wrappedConnection = $connection->getWrappedConnection();
303
304
        if ($wrappedConnection instanceof ServerInfoAwareConnection) {
305
            $version = $wrappedConnection->getServerVersion();
306
307
            $mariadb = false !== mb_stripos($version, 'mariadb');
308
            if ($mariadb && version_compare($version, '10.2.2', '<')) {
309
                return true;
310
            }
311
312
            if (!$mariadb && version_compare($version, '5.7.7', '<')) {
313
                return true;
314
            }
315
        }
316
317
        return false;
318
    }
319
}
320