Completed
Push — 4.2.0 ( f03d5e...24bd0a )
by Daniel
12:22 queued 05:26
created

TempDatabase::resetDBSchema()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 3
nop 1
dl 0
loc 15
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM\Connect;
4
5
use Exception;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Environment;
8
use SilverStripe\Core\Injector\Injectable;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\Dev\TestOnly;
11
use SilverStripe\ORM\DataExtension;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
15
class TempDatabase
16
{
17
    use Injectable;
18
19
    /**
20
     * Connection name
21
     *
22
     * @var string
23
     */
24
    protected $name = null;
25
26
    /**
27
     * Create a new temp database
28
     *
29
     * @param string $name DB Connection name to use
30
     */
31
    public function __construct($name = 'default')
32
    {
33
        $this->name = $name;
34
    }
35
36
    /**
37
     * Check if the given name matches the temp_db pattern
38
     *
39
     * @param string $name
40
     * @return bool
41
     */
42
    protected function isDBTemp($name)
43
    {
44
        $prefix = Environment::getEnv('SS_DATABASE_PREFIX') ?: 'ss_';
45
        $result = preg_match(
46
            sprintf('/^%stmpdb_[0-9]+_[0-9]+$/i', preg_quote($prefix, '/')),
47
            $name
48
        );
49
        return $result === 1;
50
    }
51
52
    /**
53
     * @return Database
54
     */
55
    protected function getConn()
56
    {
57
        return DB::get_conn($this->name);
58
    }
59
60
    /**
61
     * Returns true if we are currently using a temporary database
62
     *
63
     * @return bool
64
     */
65
    public function isUsed()
66
    {
67
        $selected = $this->getConn()->getSelectedDatabase();
68
        return $this->isDBTemp($selected);
69
    }
70
71
    /**
72
     * @return bool
73
     */
74
    public function supportsTransactions()
75
    {
76
        return static::getConn()->supportsTransactions();
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\Connect\TempDatabase::getConn() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

76
        return static::/** @scrutinizer ignore-call */ getConn()->supportsTransactions();
Loading history...
77
    }
78
79
    /**
80
     * Start a transaction for easy rollback after tests
81
     */
82
    public function startTransaction()
83
    {
84
        if (static::getConn()->supportsTransactions()) {
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\Connect\TempDatabase::getConn() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

84
        if (static::/** @scrutinizer ignore-call */ getConn()->supportsTransactions()) {
Loading history...
85
            static::getConn()->transactionStart();
86
        }
87
    }
88
89
    /**
90
     * Rollback a transaction (or trash all data if the DB doesn't support databases
91
     *
92
     * @return bool True if successfully rolled back, false otherwise. On error the DB is
93
     * killed and must be re-created. Note that calling rollbackTransaction() when there
94
     * is no transaction is counted as a failure, user code should either kill or flush the DB
95
     * as necessary
96
     */
97
    public function rollbackTransaction()
98
    {
99
        // Ensure a rollback can be performed
100
        $success = static::getConn()->supportsTransactions()
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\Connect\TempDatabase::getConn() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

100
        $success = static::/** @scrutinizer ignore-call */ getConn()->supportsTransactions()
Loading history...
101
            && static::getConn()->transactionDepth();
102
        if (!$success) {
103
            return false;
104
        }
105
        try {
106
            // Explicit false = gnostic error from transactionRollback
107
            if (static::getConn()->transactionRollback() === false) {
108
                return false;
109
            }
110
            return true;
111
        } catch (DatabaseException $ex) {
112
            return false;
113
        }
114
    }
115
116
    /**
117
     * Destroy the current temp database
118
     */
119
    public function kill()
120
    {
121
        // Nothing to kill
122
        if (!$this->isUsed()) {
123
            return;
124
        }
125
126
        // Rollback any transactions (note: Success ignored)
127
        $this->rollbackTransaction();
128
129
        // Check the database actually exists
130
        $dbConn = $this->getConn();
131
        $dbName = $dbConn->getSelectedDatabase();
132
        if (!$dbConn->databaseExists($dbName)) {
133
            return;
134
        }
135
136
        // Some DataExtensions keep a static cache of information that needs to
137
        // be reset whenever the database is killed
138
        foreach (ClassInfo::subclassesFor(DataExtension::class) as $class) {
139
            $toCall = array($class, 'on_db_reset');
140
            if (is_callable($toCall)) {
141
                call_user_func($toCall);
142
            }
143
        }
144
145
        $dbConn->dropSelectedDatabase();
146
    }
147
148
    /**
149
     * Remove all content from the temporary database.
150
     */
151
    public function clearAllData()
152
    {
153
        if (!$this->isUsed()) {
154
            return;
155
        }
156
157
        $this->getConn()->clearAllData();
158
159
        // Some DataExtensions keep a static cache of information that needs to
160
        // be reset whenever the database is cleaned out
161
        $classes = array_merge(
162
            ClassInfo::subclassesFor(DataExtension::class),
163
            ClassInfo::subclassesFor(DataObject::class)
164
        );
165
        foreach ($classes as $class) {
166
            $toCall = array($class, 'on_db_reset');
167
            if (is_callable($toCall)) {
168
                call_user_func($toCall);
169
            }
170
        }
171
    }
172
173
    /**
174
     * Create temp DB without creating extra objects
175
     *
176
     * @return string DB name
177
     */
178
    public function build()
179
    {
180
        // Disable PHPUnit error handling
181
        $oldErrorHandler = set_error_handler(null);
182
183
        // Create a temporary database, and force the connection to use UTC for time
184
        $dbConn = $this->getConn();
185
        $prefix = Environment::getEnv('SS_DATABASE_PREFIX') ?: 'ss_';
186
        do {
187
            $dbname = strtolower(sprintf('%stmpdb_%s_%s', $prefix, time(), rand(1000000, 9999999)));
188
        } while ($dbConn->databaseExists($dbname));
189
190
        $dbConn->selectDatabase($dbname, true);
191
192
        $this->resetDBSchema();
193
194
        // Reinstate PHPUnit error handling
195
        set_error_handler($oldErrorHandler);
196
197
        // Ensure test db is killed on exit
198
        register_shutdown_function(function () {
199
            try {
200
                $this->kill();
201
            } catch (Exception $ex) {
202
                // An exception thrown while trying to remove a test database shouldn't fail a build, ignore
203
            }
204
        });
205
206
        return $dbname;
207
    }
208
209
    /**
210
     * Rebuild all database tables
211
     *
212
     * @param array $extraDataObjects
213
     */
214
    protected function rebuildTables($extraDataObjects = [])
215
    {
216
        DataObject::reset();
217
218
        // clear singletons, they're caching old extension info which is used in DatabaseAdmin->doBuild()
219
        Injector::inst()->unregisterObjects(DataObject::class);
220
221
        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
222
        array_shift($dataClasses);
223
224
        $schema = $this->getConn()->getSchemaManager();
225
        $schema->quiet();
226
        $schema->schemaUpdate(function () use ($dataClasses, $extraDataObjects) {
227
            foreach ($dataClasses as $dataClass) {
228
                // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
229
                if (class_exists($dataClass)) {
230
                    $SNG = singleton($dataClass);
231
                    if (!($SNG instanceof TestOnly)) {
232
                        $SNG->requireTable();
233
                    }
234
                }
235
            }
236
237
            // If we have additional dataobjects which need schema, do so here:
238
            if ($extraDataObjects) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extraDataObjects of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
239
                foreach ($extraDataObjects as $dataClass) {
240
                    $SNG = singleton($dataClass);
241
                    if (singleton($dataClass) instanceof DataObject) {
242
                        $SNG->requireTable();
243
                    }
244
                }
245
            }
246
        });
247
248
        ClassInfo::reset_db_cache();
249
        DataObject::singleton()->flushCache();
250
    }
251
252
    /**
253
     * Clear all temp DBs on this connection
254
     *
255
     * Note: This will output results to stdout unless suppressOutput
256
     * is set on the current db schema
257
     */
258
    public function deleteAll()
259
    {
260
        $schema = $this->getConn()->getSchemaManager();
261
        foreach ($schema->databaseList() as $dbName) {
262
            if ($this->isDBTemp($dbName)) {
263
                $schema->dropDatabase($dbName);
264
                $schema->alterationMessage("Dropped database \"$dbName\"", 'deleted');
265
                flush();
266
            }
267
        }
268
    }
269
270
    /**
271
     * Reset the testing database's schema.
272
     *
273
     * @param array $extraDataObjects List of extra dataobjects to build
274
     */
275
    public function resetDBSchema(array $extraDataObjects = [])
276
    {
277
        // Skip if no DB
278
        if (!$this->isUsed()) {
279
            return;
280
        }
281
282
        try {
283
            $this->rebuildTables($extraDataObjects);
284
        } catch (DatabaseException $ex) {
285
            // In case of error during build force a hard reset
286
            // e.g. pgsql doesn't allow schema updates inside transactions
287
            $this->kill();
288
            $this->build();
289
            $this->rebuildTables($extraDataObjects);
290
        }
291
    }
292
}
293