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

j-schumann / symfony-addons / 36162186593

25 Sep 2026 04:33PM UTC coverage: 55.511%. First build
36162186593

push

github

web-flow
Fix: content type error (#76)

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\Metadata\DatabaseMetadataRow;
15
use Doctrine\DBAL\Schema\SQLiteSchemaManager;
16
use Doctrine\ORM\EntityManagerInterface;
17
use Doctrine\ORM\Tools\SchemaTool;
18
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
19
use Symfony\Component\DependencyInjection\ContainerInterface;
20
use Symfony\Component\HttpKernel\KernelInterface;
21

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

172
                break;
19 ✔
173
        }
174

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

181
        return $kernel;
21 ✔
182
    }
183

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

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

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

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

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

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

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

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

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

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

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

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

256
            $dbExists = false;
×
257
        }
258

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

351
            return;
×
352
        }
353

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

367
            return;
×
368
        }
369

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

387
            return;
×
388
        }
389

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

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

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

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

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

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

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

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

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

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

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

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

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

491
    protected static function fixtureCleanup(): void
492
    {
493
        static::$fixtures = null;
×
494
    }
495
}
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