• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

j-schumann / symfony-addons / 36158846623

25 Sep 2026 04:08PM UTC coverage: 55.511%. First build
36158846623

Pull #76

github

web-flow
Merge 1cee6cca6 into e4bacbbdd
Pull Request #76: Fix: content type error

9 of 10 new or added lines in 2 files covered. (90.0%)

559 of 1007 relevant lines covered (55.51%)

3.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.84
/src/PHPUnit/RefreshDatabaseTrait.php
1
<?php
2

3
namespace Vrok\SymfonyAddons\PHPUnit;
4

5
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
6
use Doctrine\Common\DataFixtures\Purger\ORMPurger;
7
use Doctrine\DBAL\DriverManager;
8
use Doctrine\DBAL\Platforms\AbstractPlatform;
9
use Doctrine\DBAL\Platforms\MariaDBPlatform;
10
use Doctrine\DBAL\Platforms\MySQLPlatform;
11
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
12
use Doctrine\DBAL\Platforms\SQLitePlatform;
13
use Doctrine\DBAL\Platforms\SQLServerPlatform;
14
use Doctrine\DBAL\Schema\SQLiteSchemaManager;
15
use Doctrine\ORM\EntityManagerInterface;
16
use Doctrine\ORM\Tools\SchemaTool;
17
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
18
use Symfony\Component\DependencyInjection\ContainerInterface;
19
use Symfony\Component\HttpKernel\KernelInterface;
20

21
/**
22
 * Idea from DoctrineTestBundle & hautelook/AliceBundle: We want to force the test DB to have the
23
 * current schema and load all test fixtures (group=test) so the DB is in a known state for each
24
 * test (meaning each time the kernel is booted).
25
 *
26
 * static::$fixtureGroups can be customized in setUpBeforeClass()
27
 *
28
 * The cleanup method for the database can be overwritten by setting the ENV DB_CLEANUP_METHOD (e.g.
29
 * in phpunit.xml.dist).
30
 *
31
 * "purge" will update the DB schema once and afterward only purges all tables, may require
32
 * Vrok\DoctrineAddons\DBAL\Platforms\PostgreSQLTestPlatform to cascade the purge. On SQLServer the
33
 * tables are always emptied with DELETE, as TRUNCATE does not work there with foreign keys; on
34
 * MySQL/MariaDB DELETE is only the default and DB_PURGE_MODE switches back to TRUNCATE.
35
 *
36
 * "dropSchema" will drop all tables (and indices) and recreate them before each test, use this when
37
 * a test requires a genuinely fresh schema.
38
 *
39
 * "dropDatabase" will drop the entire database and recreate it before each test.
40
 */
41
trait RefreshDatabaseTrait
42
{
43
    /**
44
     * @var string[] allowed values for the ENV DB_CLEANUP_METHOD
45
     */
46
    private const array CLEANUP_METHODS = ['purge', 'dropSchema', 'dropDatabase'];
47

48
    /**
49
     * @var string[] allowed values for the ENV DB_PURGE_MODE
50
     */
51
    private const array PURGE_MODES = ['delete', 'truncate'];
52

53
    /**
54
     * @var array fixture group(s) to apply
55
     */
56
    protected static array $fixtureGroups = ['test'];
57

58
    /**
59
     * @var array|null fixture cache
60
     */
61
    protected static ?array $fixtures = null;
62

63
    /**
64
     * @var array|null cache for the tables that have an auto-increment column
65
     */
66
    private static ?array $identityTables = null;
67

68
    /**
69
     * @var bool Flag whether the db setup is done (db exists, schema is up to date)
70
     */
71
    protected static bool $setupComplete = false;
72

73
    /**
74
     * Called on each test that calls bootKernel() or uses createClient().
75
     */
76
    protected static function bootKernel(array $options = []): KernelInterface
77
    {
78
        static::ensureKernelTestCase();
23 ✔
79

80
        $kernel = parent::bootKernel($options);
23 ✔
81
        $container = static::getContainer();
23 ✔
82
        $entityManager = $container->get('doctrine')->getManager();
23 ✔
83
        $executor = static::getExecutor($entityManager);
23 ✔
84

85
        $cleanupMethod = $_ENV['DB_CLEANUP_METHOD'] ?? 'purge';
23 ✔
86
        if (!\is_string($cleanupMethod) || !\in_array($cleanupMethod, self::CLEANUP_METHODS, true)) {
23 ✔
87
            $given = \is_string($cleanupMethod) ? $cleanupMethod : get_debug_type($cleanupMethod);
1 ✔
88
            $allowed = implode(', ', self::CLEANUP_METHODS);
1 ✔
89
            throw new \InvalidArgumentException("Unknown DB_CLEANUP_METHOD '$given', allowed values are: $allowed");
1 ✔
90
        }
91

92
        switch ($cleanupMethod) {
93
            case 'dropDatabase':
22 ✔
94
                static::recreateDatabase($entityManager, true);
1 ✔
95
                static::updateSchema($entityManager);
1 ✔
96
                break;
1 ✔
97

98
            case 'dropSchema':
21 ✔
99
                if (!static::$setupComplete) {
1 ✔
100
                    static::recreateDatabase($entityManager);
×
101
                    static::$setupComplete = true;
×
102
                }
103

104
                static::updateSchema($entityManager, true);
1 ✔
105
                break;
1 ✔
106

107
            case 'purge':
20 ✔
108
                // only on the first test: make sure the db exists and the schema is up to date
109
                if (!static::$setupComplete) {
20 ✔
110
                    static::recreateDatabase($entityManager);
2 ✔
111
                    static::updateSchema($entityManager);
2 ✔
112
                    static::$setupComplete = true;
2 ✔
113
                }
114

115
                $connection = $executor->getObjectManager()->getConnection();
20 ✔
116
                $platform = $connection->getDatabasePlatform();
20 ✔
117
                $isMysql = $platform instanceof MySQLPlatform || $platform instanceof MariaDBPlatform;
20 ✔
118
                $purgeMode = $_ENV['DB_PURGE_MODE'] ?? 'delete';
20 ✔
119
                if (!\is_string($purgeMode) || !\in_array($purgeMode, self::PURGE_MODES, true)) {
20 ✔
120
                    $given = \is_string($purgeMode) ? $purgeMode : get_debug_type($purgeMode);
1 ✔
121
                    $allowed = implode(', ', self::PURGE_MODES);
1 ✔
122
                    throw new \InvalidArgumentException("Unknown DB_PURGE_MODE '$given', allowed values are: $allowed");
1 ✔
123
                }
124

125
                // In MySQL/MariaDB we need to disable foreign key checks, as the automatic table
126
                // ordering does not help us when we have self-referencing tables.
127
                if ($isMysql) {
19 ✔
128
                    $connection->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
×
129
                }
130

131
                // SQLServer has no choice: TRUNCATE does not work with foreign keys there, and
132
                // "EXEC sp_MSforeachtable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'" does not help
133
                // either. On MySQL/MariaDB it is a preference: TRUNCATE is a DDL operation, InnoDB
134
                // drops and recreates the tablespace file for each table and each test, which is
135
                // usually more expensive than emptying the tables with DELETE, so DELETE is the
136
                // default and DB_PURGE_MODE=truncate opts out of it. Either way, DELETE keeps the
137
                // auto-increment counters, so they have to be reset afterward.
138
                $purgeWithDelete = $platform instanceof SQLServerPlatform
19 ✔
139
                    || ($isMysql && 'truncate' !== $purgeMode);
19 ✔
140
                if ($purgeWithDelete) {
19 ✔
141
                    $executor->getPurger()->setPurgeMode(ORMPurger::PURGE_MODE_DELETE);
×
142
                }
143

144
                // Purge even when no fixtures are defined, e.g., for tests that require an empty
145
                // database, like import tests. Fix for PHP8: purge separately from inserting the
146
                // fixtures, as execute() would wrap the TRUNCATE in a transaction which MySQL
147
                // auto-commits when DDL queries are executed, which throws an exception in the
148
                // entityManager ("There is no active
149
                // transaction", @see https://github.com/doctrine/migrations/issues/1104)
150
                // because he does not check if a transaction is still open before calling commit().
151
                $executor->purge();
19 ✔
152

153
                // A TRUNCATE on MySQL/MariaDB is the only purge that resets the identity
154
                // generators for us. PostgreSQL keeps its sequences unless the TRUNCATE says
155
                // RESTART IDENTITY, SQLite empties the tables with DELETE whatever the purge mode
156
                // says, and where we purged with DELETE there was no TRUNCATE to reset anything.
157
                // So everywhere else we do it ourselves, as tests may rely on the generated IDs
158
                // (e.g. when asserting on IRIs like /items/1).
159
                $purgeResetIdentities = $isMysql && !$purgeWithDelete;
19 ✔
160
                if (!$purgeResetIdentities) {
19 ✔
161
                    static::resetIdentities($entityManager, $platform);
19 ✔
162
                }
163

164
                // Restore the default: the checks were only disabled for the purge, the fixtures
165
                // and the test itself should run with the same settings as the application, e.g.
166
                // for tests that assert that a foreign key violation occurs.
167
                if ($isMysql) {
19 ✔
168
                    $connection->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
×
169
                }
170

171
                break;
19 ✔
172
        }
173

174
        // now load any fixtures configured for "test" (or overwritten groups)
175
        $fixtures = static::getFixtures($container);
21 ✔
176
        if ([] !== $fixtures) {
21 ✔
177
            $executor->execute($fixtures, true);
×
178
        }
179

180
        return $kernel;
21 ✔
181
    }
182

183
    protected static function ensureKernelTestCase(): void
184
    {
185
        if (!is_a(static::class, KernelTestCase::class, true)) {
23 ✔
186
            throw new \LogicException(\sprintf('The test class must extend "%s" to use "%s".', KernelTestCase::class, static::class));
×
187
        }
188
    }
189

190
    /**
191
     * (Drops and re-) creates the (test) database if it does not exist. This code tries to
192
     * duplicate the behavior of the doctrine:database:drop / doctrine:schema:create commands in the
193
     * DoctrineBundle.
194
     *
195
     * @param bool $drop if true, the method deletes an existing database before recreating it, else
196
     *                   the database is only created when it does not exist
197
     */
198
    protected static function recreateDatabase(
199
        EntityManagerInterface $em,
200
        bool $drop = false,
201
    ): void {
202
        $connection = $em->getConnection();
3 ✔
203
        $params = $params['primary'] ?? $connection->getParams();
3 ✔
204

205
        // name will already contain the dbname_suffix (and the TEST_TOKEN) if any is configured
206
        $dbName = $params['path'] ?? $params['dbname'] ?? false;
3 ✔
207
        if (!$dbName) {
3 ✔
208
            throw new \RuntimeException("Connection does not contain a 'dbname' or 'path' parameter, don't know how to proceed, aborting.");
×
209
        }
210

211
        unset($params['dbname'], $params['path']);
3 ✔
212
        if ($connection->getDatabasePlatform() instanceof PostgreSQLPlatform) {
3 ✔
213
            $params['dbname'] = $params['default_dbname'] ?? 'postgres';
×
214
        }
215

216
        $tempConnection = DriverManager::getConnection($params, $connection->getConfiguration());
3 ✔
217
        $schemaManager = $tempConnection->createSchemaManager();
3 ✔
218

219
        // SQLite does not support checking for existing / dropping / creating databases via
220
        // Doctrine -> special handling here
221
        if ($schemaManager instanceof SQLiteSchemaManager) {
3 ✔
222
            if ($drop && file_exists($dbName)) {
3 ✔
223
                unlink($dbName);
1 ✔
224
            }
225

226
            // the database file will be automatically created on first use, no need to create now
227
            return;
3 ✔
228
        }
229

230
        // @todo when DBAL 5.0 comes out: switch to this method, to replace the
231
        // deprecated/removed listDatabases method $dbExists = self::databaseExists($em, $dbName);
232
        $dbExists = \in_array($dbName, $schemaManager->listDatabases(), true);
×
233

234
        if ($drop && $dbExists) {
×
235
            // close the current connection in the em, it would be invalid anyway after the drop
236
            $connection->close();
×
237

238
            // For Postgres, closing the old connection is not enough to prevent: 'ERROR: database
239
            // "db_test" is being accessed by other users'
240
            if ($tempConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) {
×
241
                $tempConnection->executeStatement(
×
242
                    'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()',
×
243
                    [$dbName]
×
244
                );
×
245
            }
246

247
            if ($tempConnection->getDatabasePlatform() instanceof SQLServerPlatform) {
×
248
                $tempConnection->executeStatement(
×
249
                    "USE master; ALTER DATABASE $dbName SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
×
250
                );
×
251
            }
252

253
            $schemaManager->dropDatabase($dbName);
×
254

255
            $dbExists = false;
×
256
        }
257

258
        // Create the database only if it doesn't already exist. Skip for SQLite as it creates
259
        // database files automatically and this call would throw an exception.
260
        if (!$dbExists && !$schemaManager instanceof SQLiteSchemaManager) {
×
261
            $schemaManager->createDatabase($dbName);
×
262
        }
263

264
        $tempConnection->close();
×
265
    }
266

267
    /**
268
     * Brings the db schema to the newest version.
269
     *
270
     * @param bool $drop if true, the method drops the current schema first, e.g. to reset all data
271
     */
272
    protected static function updateSchema(
273
        EntityManagerInterface $em,
274
        bool $drop = false,
275
    ): void {
276
        $metadatas = $em->getMetadataFactory()->getAllMetadata();
4 ✔
277
        if ([] === $metadatas) {
4 ✔
278
            return;
×
279
        }
280

281
        $schemaTool = new SchemaTool($em);
4 ✔
282

283
        if ($drop) {
4 ✔
284
            // The method name is misleading; it only drops the elements within the database, not
285
            // the db itself...
286
            $schemaTool->dropDatabase();
1 ✔
287
        }
288

289
        $schemaTool->updateSchema($metadatas);
4 ✔
290
    }
291

292
    /**
293
     * Use a static fixture cache as we need them before each test.
294
     */
295
    protected static function getFixtures(ContainerInterface $container): array
296
    {
297
        if ([] === static::$fixtureGroups) {
21 ✔
298
            // the fixture loader returns all possible fixtures if called with an empty array ->
299
            // catch here
300
            return [];
×
301
        }
302

303
        if (\is_array(static::$fixtures)) {
21 ✔
304
            return static::$fixtures;
20 ✔
305
        }
306

307
        $fixturesLoader = $container->get('doctrine.fixtures.loader');
2 ✔
308
        static::$fixtures = $fixturesLoader->getFixtures(static::$fixtureGroups);
2 ✔
309

310
        return static::$fixtures;
2 ✔
311
    }
312

313
    /**
314
     * Returns a new executor instance, we need it before each test execution.
315
     */
316
    protected static function getExecutor(EntityManagerInterface $em): ORMExecutor
317
    {
318
        $purger = new ORMPurger($em);
23 ✔
319
        $purger->setPurgeMode(ORMPurger::PURGE_MODE_TRUNCATE);
23 ✔
320

321
        // don't use a static Executor, it contains the EM which could be closed through (expected)
322
        // exceptions and would not work
323
        return new ORMExecutor($em, $purger);
23 ✔
324
    }
325

326
    /**
327
     * Resets the identity generators of all tables that have one, so the records created in the
328
     * tests always receive the same IDs.
329
     *
330
     * Must be called after the purge and before the fixtures are loaded.
331
     */
332
    protected static function resetIdentities(
333
        EntityManagerInterface $em,
334
        AbstractPlatform $platform,
335
    ): void {
336
        $identityTables = static::getIdentityTables($em, $platform);
19 ✔
337
        if ([] === $identityTables) {
19 ✔
338
            return;
×
339
        }
340

341
        $connection = $em->getConnection();
19 ✔
342

343
        if ($platform instanceof MySQLPlatform || $platform instanceof MariaDBPlatform) {
19 ✔
344
            foreach (array_keys($identityTables) as $table) {
×
345
                // the value is clamped to MAX(id) + 1, so this is a no-op for a table that was
346
                // excluded from the purge and still has rows
347
                $connection->executeStatement("ALTER TABLE $table AUTO_INCREMENT = 1");
×
348
            }
349

350
            return;
×
351
        }
352

353
        if ($platform instanceof PostgreSQLPlatform) {
19 ✔
354
            // TRUNCATE keeps the sequences, only "TRUNCATE ... RESTART IDENTITY" would reset them,
355
            // @see Vrok\DoctrineAddons\DBAL\Platforms\PostgreSQLTestPlatform
356
            foreach ($identityTables as $table => $meta) {
×
357
                // is_called = false lets the next record use the value itself. The table name is
358
                // passed quoted, as pg_get_serial_sequence treats its first argument as a
359
                // (potentially qualified) name and would lowercase an unquoted one.
360
                $connection->executeQuery(
×
361
                    'SELECT setval(pg_get_serial_sequence(?, ?), 1, false)',
×
362
                    [$table, $meta['column']]
×
363
                );
×
364
            }
365

366
            return;
×
367
        }
368

369
        if ($platform instanceof SQLServerPlatform) {
19 ✔
370
            foreach (array_keys($identityTables) as $table) {
×
371
                // DBCC CHECKIDENT makes the *next* record use RESEED + 1, but only for a table that
372
                // was inserted into before: for a table that was never used, the first record uses
373
                // the reseed value itself, which would be 0. So we only reseed tables that have a
374
                // current identity value.
375
                $connection->executeStatement(
×
376
                    'DECLARE @table NVARCHAR(776) = ?;
×
377
                     IF EXISTS (
378
                         SELECT 1 FROM sys.identity_columns
379
                         WHERE object_id = OBJECT_ID(@table) AND last_value IS NOT NULL
380
                     )
381
                         DBCC CHECKIDENT (@table, RESEED, 0) WITH NO_INFOMSGS',
×
382
                    [$table]
×
383
                );
×
384
            }
385

386
            return;
×
387
        }
388

389
        if ($platform instanceof SQLitePlatform) {
19 ✔
390
            // SQLite stores the counters of all AUTOINCREMENT columns in this table, it only exists
391
            // when at least one such column is used
392
            $sequenceTableExists = (bool) $connection->fetchOne(
19 ✔
393
                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'"
19 ✔
394
            );
19 ✔
395
            if (!$sequenceTableExists) {
19 ✔
396
                return;
×
397
            }
398

399
            foreach ($identityTables as $meta) {
19 ✔
400
                // the counters are stored with the unquoted table name
401
                $connection->executeStatement(
19 ✔
402
                    'DELETE FROM sqlite_sequence WHERE name = ?',
19 ✔
403
                    [$meta['name']]
19 ✔
404
                );
19 ✔
405
            }
406
        }
407
    }
408

409
    /**
410
     * Returns all tables that have an identity / auto-increment column, in the order in which the
411
     * mapping defines them.
412
     *
413
     * @return array<string, array{name: string, column: string}>
414
     */
415
    protected static function getIdentityTables(
416
        EntityManagerInterface $em,
417
        AbstractPlatform $platform,
418
    ): array {
419
        if (null !== self::$identityTables) {
19 ✔
420
            return self::$identityTables;
18 ✔
421
        }
422

423
        $quoteStrategy = $em->getConfiguration()->getQuoteStrategy();
2 ✔
424
        $tables = [];
2 ✔
425

426
        foreach ($em->getMetadataFactory()->getAllMetadata() as $meta) {
2 ✔
427
            if ($meta->isMappedSuperclass
2 ✔
428
                || (isset($meta->isEmbeddedClass) && $meta->isEmbeddedClass)
2 ✔
429
            ) {
430
                continue;
2 ✔
431
            }
432

433
            // Only the root class of a hierarchy owns the identity column: With SINGLE_TABLE the
434
            // children share the table of the root, with JOINED they have their own table, but its
435
            // primary key is a foreign key to the root and not auto-increment. The children inherit
436
            // generatorType = IDENTITY in both cases,
437
            // @see ClassMetadataFactory::inheritIdGeneratorMapping()
438
            if ($meta->name !== $meta->rootEntityName) {
2 ✔
439
                continue;
2 ✔
440
            }
441

442
            // the conditions under which the SchemaTool sets autoincrement=true
443
            $identifierFields = $meta->getIdentifierFieldNames();
2 ✔
444
            if (!$meta->isIdGeneratorIdentity() || 1 !== \count($identifierFields)) {
2 ✔
445
                continue;
2 ✔
446
            }
447

448
            // multiple entities can be mapped to the same table -> deduplicate
449
            $tables[$quoteStrategy->getTableName($meta, $platform)] = [
2 ✔
450
                'name'   => $meta->getTableName(),
2 ✔
451
                'column' => $meta->getColumnName($identifierFields[0]),
2 ✔
452
            ];
2 ✔
453
        }
454

455
        return self::$identityTables = $tables;
2 ✔
456
    }
457

458
    /**
459
     * @todo this method only works w/ DBAL >= 4.4, as the createMetadataProvider
460
     * is first implemented there. But also schemaManager->listDatabases is only deprecated since
461
     * 4.4, so we stay with that, until we actually want to support 5.0 w/o listDatabases.
462
     *
463
     * Returns the list of databases the current connection sees. Must be called with the "old"
464
     * entityManager, that has the database name set in its connection, or we will receive "A
465
     * database is required for the method: Doctrine\DBAL\Platforms\MySQL\MySQLMetadataProvider" on
466
     * MariaDB and MySQL.
467
     */
468
    private static function databaseExists(EntityManagerInterface $em, string $dbName): bool
469
    {
470
        // We cannot use schemaManager->introspectDatabaseNames, as this would require the
471
        // $tempConnection to have a DB name set for MySQL/MariaDB. But we can't set one, as it
472
        // would fail when the database indeed does not exist, that's why we created the
473
        // $tempConnection in the first place. All was working well when
474
        // schemaManager->listDatabases was not yet deprecated...
475
        $connection = $em->getConnection();
×
476

477
        // So instead, we create the provider manually with the old connection, in the hope that
478
        // this works even with the EM closed from previous test run exceptions...
479
        $metaProvider = $connection->getDatabasePlatform()
×
480
            ->createMetadataProvider($connection);
×
481

482
        $dbNames = array_map(
×
483
            static fn ($n) => $n->getDatabaseName(),
×
NEW
484
            iterator_to_array($metaProvider->getAllDatabaseNames())
×
485
        );
×
486

487
        return \in_array($dbName, $dbNames, true);
×
488
    }
489

490
    protected static function fixtureCleanup(): void
491
    {
492
        static::$fixtures = null;
×
493
    }
494
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc