Passed
Push — multiple-schemas-support ( 638643 )
by Damien
08:07
created

SchemaManager::collectAuditableEntities()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
c 0
b 0
f 0
dl 0
loc 21
rs 9.9
cc 4
nc 4
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace DH\Auditor\Provider\Doctrine\Persistence\Schema;
6
7
use DH\Auditor\Provider\Doctrine\Configuration;
8
use DH\Auditor\Provider\Doctrine\DoctrineProvider;
9
use DH\Auditor\Provider\Doctrine\Persistence\Helper\DoctrineHelper;
10
use DH\Auditor\Provider\Doctrine\Persistence\Helper\PlatformHelper;
11
use DH\Auditor\Provider\Doctrine\Persistence\Helper\SchemaHelper;
12
use DH\Auditor\Provider\Doctrine\Service\AuditingService;
13
use DH\Auditor\Provider\Doctrine\Service\StorageService;
14
use Doctrine\DBAL\Connection;
15
use Doctrine\DBAL\Platforms\AbstractPlatform;
16
use Doctrine\DBAL\Schema\Schema;
17
use Doctrine\DBAL\Schema\SchemaException;
18
use Doctrine\DBAL\Schema\Table;
19
use Doctrine\DBAL\Types\Types;
20
use Doctrine\ORM\EntityManagerInterface;
21
22
/**
23
 * @see \DH\Auditor\Tests\Provider\Doctrine\Persistence\Schema\SchemaManagerTest
24
 */
25
class SchemaManager
26
{
27
    private DoctrineProvider $provider;
28
29
    public function __construct(DoctrineProvider $provider)
30
    {
31
        $this->provider = $provider;
32
    }
33
34
    public function updateAuditSchema(?array $sqls = null, ?callable $callback = null): void
35
    {
36
        if (null === $sqls) {
37
            $sqls = $this->getUpdateAuditSchemaSql();
38
        }
39
40
        /** @var StorageService[] $storageServices */
41
        $storageServices = $this->provider->getStorageServices();
42
        foreach ($sqls as $name => $queries) {
43
            $connection = $storageServices[$name]->getEntityManager()->getConnection();
44
            foreach ($queries as $index => $sql) {
45
                $statement = $connection->prepare($sql);
46
                DoctrineHelper::executeStatement($statement);
47
48
                if (null !== $callback) {
49
                    $callback([
50
                        'total' => \count($sqls),
51
                        'current' => $index,
52
                    ]);
53
                }
54
            }
55
        }
56
    }
57
58
    /**
59
     * Returns an array of audit table names indexed by entity FQN.
60
     * Only auditable entities are considered.
61
     */
62
    public function getAuditableTableNames(EntityManagerInterface $entityManager): array
63
    {
64
        $metadataDriver = $entityManager->getConfiguration()->getMetadataDriverImpl();
65
        $entities = [];
66
        if (null !== $metadataDriver) {
67
            $entities = $metadataDriver->getAllClassNames();
68
        }
69
        $audited = [];
70
        foreach ($entities as $entity) {
71
            if ($this->provider->isAuditable($entity)) {
72
                $audited[$entity] = $entityManager->getClassMetadata($entity)->getTableName();
73
            }
74
        }
75
        ksort($audited);
76
77
        return $audited;
78
    }
79
80
    public function collectAuditableEntities(): array
81
    {
82
        // auditable entities by storage entity manager
83
        $repository = [];
84
85
        /** @var AuditingService[] $auditingServices */
86
        $auditingServices = $this->provider->getAuditingServices();
87
        foreach ($auditingServices as $auditingService) {
88
            $classes = $this->getAuditableTableNames($auditingService->getEntityManager());
89
            // Populate the auditable entities repository
90
            foreach ($classes as $entity => $tableName) {
91
                $storageService = $this->provider->getStorageServiceForEntity($entity);
92
                $key = array_search($storageService, $this->provider->getStorageServices(), true);
93
                if (!isset($repository[$key])) {
94
                    $repository[$key] = [];
95
                }
96
                $repository[$key][$entity] = $tableName;
97
            }
98
        }
99
100
        return $repository;
101
    }
102
103
    public function getUpdateAuditSchemaSql(): array
104
    {
105
        /** @var Configuration $configuration */
106
        $configuration = $this->provider->getConfiguration();
107
108
        /** @var StorageService[] $storageServices */
109
        $storageServices = $this->provider->getStorageServices();
110
111
        // Collect auditable entities from auditing entity managers
112
        $repository = $this->collectAuditableEntities();
113
114
        $entities = $configuration->getEntities();
115
116
        // Compute and collect SQL queries
117
        $sqls = [];
118
        foreach ($repository as $name => $classes) {
119
            $storageConnection = $storageServices[$name]->getEntityManager()->getConnection();
120
            $storageSchemaManager = $storageConnection->createSchemaManager();
121
122
            $storageSchema = $storageSchemaManager->introspectSchema();
123
            $fromSchema = clone $storageSchema;
124
125
            $processed = [];
126
            foreach ($classes as $entityFQCN => $tableName) {
127
                if (!\in_array($entityFQCN, $processed, true)) {
128
                    /** @var string $auditTablename */
129
                    $auditTablename = $this->resolveAuditTableName($entities[$entityFQCN], $configuration, $storageConnection->getDatabasePlatform());
130
131
                    if ($storageSchema->hasTable($auditTablename)) {
132
                        // Audit table exists, let's update it if needed
133
                        $this->updateAuditTable($entityFQCN, $storageSchema);
134
                    } else {
135
                        // Audit table does not exists, let's create it
136
                        $this->createAuditTable($entityFQCN, $storageSchema);
137
                    }
138
139
                    $processed[] = $entityFQCN;
140
                }
141
            }
142
            $sqls[$name] = DoctrineHelper::getMigrateToSql($storageConnection, $fromSchema, $storageSchema);
143
        }
144
145
        return $sqls;
146
    }
147
148
    /**
149
     * Creates an audit table.
150
     *
151
     * @throws \Doctrine\DBAL\Exception
152
     */
153
    public function createAuditTable(string $entity, ?Schema $schema = null): Schema
154
    {
155
        /** @var StorageService $storageService */
156
        $storageService = $this->provider->getStorageServiceForEntity($entity);
157
        $connection = $storageService->getEntityManager()->getConnection();
158
159
        if (null === $schema) {
160
            $schemaManager = DoctrineHelper::createSchemaManager($connection);
161
            $schema = DoctrineHelper::introspectSchema($schemaManager);
162
        }
163
164
        /** @var Configuration $configuration */
165
        $configuration = $this->provider->getConfiguration();
166
        $entities = $configuration->getEntities();
167
168
        $auditTablename = $this->resolveAuditTableName($entities[$entity], $configuration, $connection->getDatabasePlatform());
169
170
        if (null !== $auditTablename && !$schema->hasTable($auditTablename)) {
171
            $auditTable = $schema->createTable($auditTablename);
172
173
            // Add columns to audit table
174
            $isJsonSupported = PlatformHelper::isJsonSupported($connection);
175
            foreach (SchemaHelper::getAuditTableColumns() as $columnName => $struct) {
176
                if (Types::JSON === $struct['type'] && $isJsonSupported) {
177
                    $type = Types::TEXT;
178
                } else {
179
                    $type = $struct['type'];
180
                }
181
182
                $auditTable->addColumn($columnName, $type, $struct['options']);
183
            }
184
185
            // Add indices to audit table
186
            foreach (SchemaHelper::getAuditTableIndices($auditTablename) as $columnName => $struct) {
187
                if ('primary' === $struct['type']) {
188
                    $auditTable->setPrimaryKey([$columnName]);
189
                } else {
190
                    $auditTable->addIndex(
191
                        [$columnName],
192
                        $struct['name'],
193
                        [],
194
                        PlatformHelper::isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
195
                    );
196
                }
197
            }
198
        }
199
200
        return $schema;
201
    }
202
203
    /**
204
     * Ensures an audit table's structure is valid.
205
     *
206
     * @throws SchemaException
207
     * @throws \Doctrine\DBAL\Exception
208
     */
209
    public function updateAuditTable(string $entity, ?Schema $schema = null): Schema
210
    {
211
        /** @var StorageService $storageService */
212
        $storageService = $this->provider->getStorageServiceForEntity($entity);
213
        $connection = $storageService->getEntityManager()->getConnection();
214
215
        $schemaManager = DoctrineHelper::createSchemaManager($connection);
216
        if (null === $schema) {
217
            $schema = DoctrineHelper::introspectSchema($schemaManager);
218
        }
219
220
        /** @var Configuration $configuration */
221
        $configuration = $this->provider->getConfiguration();
222
        $entities = $configuration->getEntities();
223
224
        $auditTablename = $this->resolveAuditTableName($entities[$entity], $configuration, $connection->getDatabasePlatform());
225
        \assert(\is_string($auditTablename));
226
        $table = $schema->getTable($auditTablename);
227
228
        // process columns
229
        $this->processColumns($table, $table->getColumns(), SchemaHelper::getAuditTableColumns(), $connection);
230
231
        // process indices
232
        $this->processIndices($table, SchemaHelper::getAuditTableIndices($auditTablename), $connection);
233
234
        return $schema;
235
    }
236
237
    /**
238
     * Resolves table name, including namespace/schema.
239
     */
240
    public function resolveTableName(string $tableName, string $namespaceName, AbstractPlatform $platform): ?string
241
    {
242
        if (empty($namespaceName)) {
243
            $prefix = '';
244
        } elseif (!$platform->supportsSchemas()) {
245
            $prefix = $namespaceName.'__';
246
        } else {
247
            $prefix = $namespaceName.'.';
248
        }
249
250
        return $prefix.$tableName;
251
    }
252
253
    /**
254
     * Resolves audit table name, including namespace/schema.
255
     */
256
    public function resolveAuditTableName(array $entityOptions, Configuration $configuration, AbstractPlatform $platform): ?string
257
    {
258
        $tablename = $this->resolveTableName($entityOptions['table_name'], $entityOptions['audit_table_schema'], $platform);
259
        \assert(\is_string($tablename));
260
261
        return preg_replace(
262
            sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($tablename, '#')),
263
            sprintf(
264
                '$1%s$2%s',
265
                preg_quote($configuration->getTablePrefix(), '#'),
266
                preg_quote($configuration->getTableSuffix(), '#')
267
            ),
268
            $tablename
269
        );
270
    }
271
272
    /**
273
     * Computes audit table name **without** namespace/schema.
274
     */
275
    public function computeAuditTablename(array $entityOptions, Configuration $configuration, AbstractPlatform $platform): ?string
276
    {
277
        return preg_replace(
278
            sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($entityOptions['table_name'], '#')),
279
            sprintf(
280
                '$1%s$2%s',
281
                preg_quote($configuration->getTablePrefix(), '#'),
282
                preg_quote($configuration->getTableSuffix(), '#')
283
            ),
284
            $entityOptions['table_name']
285
        );
286
    }
287
288
    private function processColumns(Table $table, array $columns, array $expectedColumns, Connection $connection): void
289
    {
290
        $processed = [];
291
292
        $isJsonSupported = PlatformHelper::isJsonSupported($connection);
293
        foreach ($columns as $column) {
294
            if (\array_key_exists($column->getName(), $expectedColumns)) {
295
                // column is part of expected columns
296
                $table->dropColumn($column->getName());
297
298
                if (Types::JSON === $expectedColumns[$column->getName()]['type'] && $isJsonSupported) {
299
                    $type = Types::TEXT;
300
                } else {
301
                    $type = $expectedColumns[$column->getName()]['type'];
302
                }
303
304
                $table->addColumn($column->getName(), $type, $expectedColumns[$column->getName()]['options']);
305
            } else {
306
                // column is not part of expected columns so it has to be removed
307
                $table->dropColumn($column->getName());
308
            }
309
310
            $processed[] = $column->getName();
311
        }
312
313
        foreach ($expectedColumns as $columnName => $options) {
314
            if (!\in_array($columnName, $processed, true)) {
315
                $table->addColumn($columnName, $options['type'], $options['options']);
316
            }
317
        }
318
    }
319
320
    /**
321
     * @throws SchemaException
322
     */
323
    private function processIndices(Table $table, array $expectedIndices, Connection $connection): void
324
    {
325
        foreach ($expectedIndices as $columnName => $options) {
326
            if ('primary' === $options['type']) {
327
                $table->dropPrimaryKey();
328
                $table->setPrimaryKey([$columnName]);
329
            } else {
330
                if ($table->hasIndex($options['name'])) {
331
                    $table->dropIndex($options['name']);
332
                }
333
                $table->addIndex(
334
                    [$columnName],
335
                    $options['name'],
336
                    [],
337
                    PlatformHelper::isIndexLengthLimited($columnName, $connection) ? ['lengths' => [191]] : []
338
                );
339
            }
340
        }
341
    }
342
}
343