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

j-schumann / symfony-addons / 23261223160

18 Mar 2026 06:41PM UTC coverage: 53.674% (-0.7%) from 54.407%
23261223160

push

github

j-schumann
upd: ci deps

504 of 939 relevant lines covered (53.67%)

3.45 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Vrok\SymfonyAddons\PHPUnit;
6

7
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
8
use Doctrine\Common\DataFixtures\Purger\ORMPurger;
9
use Doctrine\DBAL\DriverManager;
10
use Doctrine\DBAL\Platforms\MariaDBPlatform;
11
use Doctrine\DBAL\Platforms\MySQLPlatform;
12
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
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:
23
 * We want to force the test DB to have the current schema and load all test
24
 * fixtures (group=test) so the DB is in a known state for each test (meaning
25
 * 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
30
 * DB_CLEANUP_METHOD (e.g. in phpunit.xml.dist).
31
 *
32
 * "purge" will update the DB schema once and afterward only purges
33
 *  all tables, may require Vrok\DoctrineAddons\DBAL\Platforms\{Mariadb|PostgreSQL}TestPlatform
34
 *  to disable foreign keys / cascade purge or reset identities before running.
35
 *
36
 * "dropSchema" will drop all tables (and indices) and recreate them before each
37
 * test, use this for databases that do not support disabling foreign keys like
38
 * MS SqlServer. From experience, is also much faster than purge, at least on
39
 * Postgres.
40
 *
41
 * "dropDatabase" will drop the entire database and recreate it before each test
42
 * run, this should be even faster than "dropSchema".
43
 */
44
trait RefreshDatabaseTrait
45
{
46
    /**
47
     * @var array fixture group(s) to apply
48
     */
49
    protected static array $fixtureGroups = ['test'];
50

51
    /**
52
     * @var array|null fixture cache
53
     */
54
    protected static ?array $fixtures = null;
55

56
    /**
57
     * @var bool Flag whether the db setup is done (db exists, schema is up to
58
     *           date)
59
     */
60
    protected static bool $setupComplete = false;
61

62
    /**
63
     * Called on each test that calls bootKernel() or uses createClient().
64
     */
65
    protected static function bootKernel(array $options = []): KernelInterface
66
    {
67
        static::ensureKernelTestCase();
16✔
68

69
        $kernel = parent::bootKernel($options);
16✔
70
        $container = static::getContainer();
16✔
71
        $entityManager = $container->get('doctrine')->getManager();
16✔
72
        $executor = static::getExecutor($entityManager);
16✔
73

74
        switch ($_ENV['DB_CLEANUP_METHOD'] ?? 'purge') {
16✔
75
            case 'dropDatabase':
16✔
76
                static::recreateDatabase($entityManager, true);
1✔
77
                static::updateSchema($entityManager);
1✔
78
                break;
1✔
79

80
            case 'dropSchema':
15✔
81
                if (!static::$setupComplete) {
1✔
82
                    static::recreateDatabase($entityManager);
×
83
                    static::$setupComplete = true;
×
84
                }
85

86
                static::updateSchema($entityManager, true);
1✔
87
                break;
1✔
88

89
            case 'purge':
14✔
90
            default:
91
                // only required on the first test: make sure the db exists and
92
                // the schema is up to date
93
                if (!static::$setupComplete) {
14✔
94
                    static::recreateDatabase($entityManager);
2✔
95
                    static::updateSchema($entityManager);
2✔
96
                    static::$setupComplete = true;
2✔
97
                }
98

99
                $connection = $executor->getObjectManager()->getConnection();
14✔
100
                $platform = $connection->getDatabasePlatform();
14✔
101

102
                // In MySQL/MariaDB we need to disable foreign key checks, as
103
                // the automatic table ordering does not help us when we have
104
                // self-referencing tables.
105
                if ($platform instanceof MySQLPlatform || $platform instanceof MariaDBPlatform) {
14✔
106
                    $connection->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
×
107
                }
108

109
                // In SQLServer, TRUNCATE does not work with foreign keys, also
110
                // using "EXEC sp_MSforeachtable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'"
111
                // does not help here. So we switch to simple delete, but this
112
                // requires us to manually reset auto-increments afterward.
113
                if ($platform instanceof SQLServerPlatform) {
14✔
114
                    $executor->getPurger()->setPurgeMode(ORMPurger::PURGE_MODE_DELETE);
×
115
                }
116

117
                // Purge even when no fixtures are defined, e.g., for tests that
118
                // require an empty database, like import tests.
119
                // Fix for PHP8: purge separately from inserting the fixtures,
120
                // as execute() would wrap the TRUNCATE in a transaction which
121
                // MySQL auto-commits when DDL queries are executed, which
122
                // throws an exception in the entityManager ("There is no active
123
                // transaction", @see https://github.com/doctrine/migrations/issues/1104)
124
                // because he does not check if a transaction is still open
125
                // before calling commit().
126
                $executor->purge();
14✔
127

128
                // See above, we have to manually reset identities on SQLServer
129
                if ($platform instanceof SQLServerPlatform) {
14✔
130
                    $executor->getObjectManager()->getConnection()->executeStatement(
×
131
                        "EXEC sp_MSforeachtable 'IF OBJECTPROPERTY(OBJECT_ID(''?''), ''TableHasIdentity'') = 1 DBCC CHECKIDENT (''?'', RESEED, 0)'"
×
132
                    );
×
133
                }
134

135
                break;
14✔
136
        }
137

138
        // now load any fixtures configured for "test" (or overwritten groups)
139
        $fixtures = static::getFixtures($container);
16✔
140
        if ([] !== $fixtures) {
16✔
141
            $executor->execute($fixtures, true);
×
142
        }
143

144
        return $kernel;
16✔
145
    }
146

147
    protected static function ensureKernelTestCase(): void
148
    {
149
        if (!is_a(static::class, KernelTestCase::class, true)) {
16✔
150
            throw new \LogicException(\sprintf('The test class must extend "%s" to use "%s".', KernelTestCase::class, static::class));
×
151
        }
152
    }
153

154
    /**
155
     * (Drops and re-) creates the (test) database if it does not exist.
156
     * This code tries to duplicate the behavior of the doctrine:database:drop
157
     * / doctrine:schema:create commands in the DoctrineBundle.
158
     *
159
     * @param bool $drop If true, the method will delete an existing database
160
     *                   before recreating it. If false, the database will only
161
     *                   be created if it doesn't exist.
162
     */
163
    protected static function recreateDatabase(
164
        EntityManagerInterface $em,
165
        bool $drop = false,
166
    ): void {
167
        $connection = $em->getConnection();
3✔
168
        $params = $params['primary'] ?? $connection->getParams();
3✔
169

170
        // this name will already contain the dbname_suffix (and the TEST_TOKEN)
171
        // if any is configured
172
        $dbName = $params['path'] ?? $params['dbname'] ?? false;
3✔
173
        if (!$dbName) {
3✔
174
            throw new \RuntimeException("Connection does not contain a 'dbname' or 'path' parameter, don't know how to proceed, aborting.");
×
175
        }
176

177
        unset($params['dbname'], $params['path']);
3✔
178
        if ($connection->getDatabasePlatform() instanceof PostgreSQLPlatform) {
3✔
179
            $params['dbname'] = $params['default_dbname'] ?? 'postgres';
×
180
        }
181

182
        $tempConnection = DriverManager::getConnection($params, $connection->getConfiguration());
3✔
183
        $schemaManager = $tempConnection->createSchemaManager();
3✔
184

185
        // SQLite does not support checking for existing / dropping / creating
186
        // databases via Doctrine -> special handling here
187
        if ($schemaManager instanceof SQLiteSchemaManager) {
3✔
188
            if ($drop && file_exists($dbName)) {
3✔
189
                unlink($dbName);
1✔
190
            }
191

192
            // the database file will be automatically created on first use,
193
            // no need to create it here
194
            return;
3✔
195
        }
196

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

202
        if ($drop && $dbExists) {
×
203
            // close the current connection in the em, it would be invalid
204
            // anyway after the drop
205
            $connection->close();
×
206

207
            // For Postgres, closing the old connection is not
208
            // enough to prevent: 'ERROR: database "db_test" is being accessed by other users'
209
            if ($tempConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) {
×
210
                $tempConnection->executeStatement(
×
211
                    'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()',
×
212
                    [$dbName]
×
213
                );
×
214
            }
215

216
            if ($tempConnection->getDatabasePlatform() instanceof SQLServerPlatform) {
×
217
                $tempConnection->executeStatement(
×
218
                    "USE master; ALTER DATABASE $dbName SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
×
219
                );
×
220
            }
221

222
            $schemaManager->dropDatabase($dbName);
×
223

224
            $dbExists = false;
×
225
        }
226

227
        // Create the database only if it doesn't already exist. Skip for SQLite
228
        // as it creates database files automatically and this call would throw
229
        // an exception.
230
        if (!$dbExists && !$schemaManager instanceof SQLiteSchemaManager) {
×
231
            $schemaManager->createDatabase($dbName);
×
232
        }
233

234
        $tempConnection->close();
×
235
    }
236

237
    /**
238
     * Brings the db schema to the newest version.
239
     *
240
     * @param bool $drop If true, the method will drop the current schema, e.g.
241
     *                   to reset all data, as dropping & recreating the schema
242
     *                   will often be faster than truncating all tables.
243
     */
244
    protected static function updateSchema(
245
        EntityManagerInterface $em,
246
        bool $drop = false,
247
    ): void {
248
        $metadatas = $em->getMetadataFactory()->getAllMetadata();
4✔
249
        if ([] === $metadatas) {
4✔
250
            return;
×
251
        }
252

253
        $schemaTool = new SchemaTool($em);
4✔
254

255
        if ($drop) {
4✔
256
            // The method name is misleading; it only drops the elements within
257
            // the database, not the db itself...
258
            $schemaTool->dropDatabase();
1✔
259
        }
260

261
        $schemaTool->updateSchema($metadatas);
4✔
262
    }
263

264
    /**
265
     * Use a static fixture cache as we need them before each test.
266
     */
267
    protected static function getFixtures(ContainerInterface $container): array
268
    {
269
        if ([] === static::$fixtureGroups) {
16✔
270
            // the fixture loader returns all possible fixtures if called
271
            // with an empty array -> catch here
272
            return [];
×
273
        }
274

275
        if (\is_array(static::$fixtures)) {
16✔
276
            return static::$fixtures;
15✔
277
        }
278

279
        $fixturesLoader = $container->get('doctrine.fixtures.loader');
2✔
280
        static::$fixtures = $fixturesLoader->getFixtures(static::$fixtureGroups);
2✔
281

282
        return static::$fixtures;
2✔
283
    }
284

285
    /**
286
     * Returns a new executor instance, we need it before each test execution.
287
     */
288
    protected static function getExecutor(EntityManagerInterface $em): ORMExecutor
289
    {
290
        $purger = new ORMPurger($em);
16✔
291
        $purger->setPurgeMode(ORMPurger::PURGE_MODE_TRUNCATE);
16✔
292

293
        // don't use a static Executor, it contains the EM which could be closed
294
        // through (expected) exceptions and would not work
295
        return new ORMExecutor($em, $purger);
16✔
296
    }
297

298
    /**
299
     * @todo this method only works w/ DBAL >= 4.4, as the createMetadataProvider
300
     * is first implemented there. But also schemaManager->listDatabases is
301
     * only deprecated since 4.4, so we stay with that, until we actually want
302
     * to support 5.0 w/o listDatabases.
303
     *
304
     * Returns the list of databases the current connection sees.
305
     * Must be called with the "old" entityManager, that has the database name
306
     * set in its connection, or we will receive "A database is required for the
307
     * method: Doctrine\DBAL\Platforms\MySQL\MySQLMetadataProvider" on MariaDB
308
     * and MySQL.
309
     */
310
    private static function databaseExists(EntityManagerInterface $em, string $dbName): bool
311
    {
312
        // We cannot use schemaManager->introspectDatabaseNames, as this would
313
        // require the $tempConnection to have a DB name set for MySQL/MariaDB.
314
        // But we can't set one, as it would fail when the database indeed does
315
        // not exist, that's why we created the $tempConnection in the first
316
        // place.
317
        // All was working well when schemaManager->listDatabases was not yet
318
        // deprecated...
319
        $connection = $em->getConnection();
×
320

321
        // So instead, we create the provider manually with the old connection,
322
        // in the hope that this works even with the EM closed from previous
323
        // test run exceptions...
324
        $metaProvider = $connection->getDatabasePlatform()
×
325
            ->createMetadataProvider($connection);
×
326

327
        $dbNames = array_map(
×
328
            static fn ($n) => $n->getDatabaseName(),
×
329
            iterator_to_array($metaProvider->getAllDatabaseNames())
×
330
        );
×
331

332
        return \in_array($dbName, $dbNames, true);
×
333
    }
334

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

© 2026 Coveralls, Inc