Pull merge.
[yaffs-website] / web / core / lib / Drupal / Core / Command / DbDumpCommand.php
1 <?php
2
3 namespace Drupal\Core\Command;
4
5 use Drupal\Component\Utility\Variable;
6 use Drupal\Core\Database\Connection;
7 use Symfony\Component\Console\Input\InputInterface;
8 use Symfony\Component\Console\Input\InputOption;
9 use Symfony\Component\Console\Output\OutputInterface;
10
11 /**
12  * Provides a command to dump the current database to a script.
13  *
14  * This script exports all tables in the given database, and all data (except
15  * for tables denoted as schema-only). The resulting script creates the tables
16  * and populates them with the exported data.
17  *
18  * @todo This command is currently only compatible with MySQL. Making it
19  *   backend-agnostic will require \Drupal\Core\Database\Schema support the
20  *   ability to retrieve table schema information. Note that using a raw
21  *   SQL dump file here (eg, generated from mysqldump or pg_dump) is not an
22  *   option since these tend to still be database-backend specific.
23  * @see https://www.drupal.org/node/301038
24  *
25  * @see \Drupal\Core\Command\DbDumpApplication
26  */
27 class DbDumpCommand extends DbCommandBase {
28
29   /**
30    * An array of table patterns to exclude completely.
31    *
32    * This excludes any lingering simpletest tables generated during test runs.
33    *
34    * @var array
35    */
36   protected $excludeTables = ['test[0-9]+'];
37
38   /**
39    * {@inheritdoc}
40    */
41   protected function configure() {
42     $this->setName('dump-database-d8-mysql')
43       ->setDescription('Dump the current database to a generation script')
44       ->addOption('schema-only', NULL, InputOption::VALUE_OPTIONAL, 'A comma separated list of tables to only export the schema without data.', 'cache.*,sessions,watchdog');
45     parent::configure();
46   }
47
48   /**
49    * {@inheritdoc}
50    */
51   protected function execute(InputInterface $input, OutputInterface $output) {
52     $connection = $this->getDatabaseConnection($input);
53
54     // If not explicitly set, disable ANSI which will break generated php.
55     if ($input->hasParameterOption(['--ansi']) !== TRUE) {
56       $output->setDecorated(FALSE);
57     }
58
59     $schema_tables = $input->getOption('schema-only');
60     $schema_tables = explode(',', $schema_tables);
61
62     $output->writeln($this->generateScript($connection, $schema_tables), OutputInterface::OUTPUT_RAW);
63   }
64
65   /**
66    * Generates the database script.
67    *
68    * @param \Drupal\Core\Database\Connection $connection
69    *   The database connection to use.
70    * @param array $schema_only
71    *   Table patterns for which to only dump the schema, no data.
72    * @return string
73    *   The PHP script.
74    */
75   protected function generateScript(Connection $connection, array $schema_only = []) {
76     $tables = '';
77
78     $schema_only_patterns = [];
79     foreach ($schema_only as $match) {
80       $schema_only_patterns[] = '/^' . $match . '$/';
81     }
82
83     foreach ($this->getTables($connection) as $table) {
84       $schema = $this->getTableSchema($connection, $table);
85       // Check for schema only.
86       if (empty($schema_only_patterns) || preg_replace($schema_only_patterns, '', $table)) {
87         $data = $this->getTableData($connection, $table);
88       }
89       else {
90         $data = [];
91       }
92       $tables .= $this->getTableScript($table, $schema, $data);
93     }
94     $script = $this->getTemplate();
95     // Substitute in the tables.
96     $script = str_replace('{{TABLES}}', trim($tables), $script);
97     return trim($script);
98   }
99
100   /**
101    * Returns a list of tables, not including those set to be excluded.
102    *
103    * @param \Drupal\Core\Database\Connection $connection
104    *   The database connection to use.
105    * @return array
106    *   An array of table names.
107    */
108   protected function getTables(Connection $connection) {
109     $tables = array_values($connection->schema()->findTables('%'));
110
111     foreach ($tables as $key => $table) {
112       // Remove any explicitly excluded tables.
113       foreach ($this->excludeTables as $pattern) {
114         if (preg_match('/^' . $pattern . '$/', $table)) {
115           unset($tables[$key]);
116         }
117       }
118     }
119
120     return $tables;
121   }
122
123   /**
124    * Returns a schema array for a given table.
125    *
126    * @param \Drupal\Core\Database\Connection $connection
127    *   The database connection to use.
128    * @param string $table
129    *   The table name.
130    *
131    * @return array
132    *   A schema array (as defined by hook_schema()).
133    *
134    * @todo This implementation is hard-coded for MySQL.
135    */
136   protected function getTableSchema(Connection $connection, $table) {
137     // Check this is MySQL.
138     if ($connection->databaseType() !== 'mysql') {
139       throw new \RuntimeException('This script can only be used with MySQL database backends.');
140     }
141
142     $query = $connection->query("SHOW FULL COLUMNS FROM {" . $table . "}");
143     $definition = [];
144     while (($row = $query->fetchAssoc()) !== FALSE) {
145       $name = $row['Field'];
146       // Parse out the field type and meta information.
147       preg_match('@([a-z]+)(?:\((\d+)(?:,(\d+))?\))?\s*(unsigned)?@', $row['Type'], $matches);
148       $type = $this->fieldTypeMap($connection, $matches[1]);
149       if ($row['Extra'] === 'auto_increment') {
150         // If this is an auto increment, then the type is 'serial'.
151         $type = 'serial';
152       }
153       $definition['fields'][$name] = [
154         'type' => $type,
155         'not null' => $row['Null'] === 'NO',
156       ];
157       if ($size = $this->fieldSizeMap($connection, $matches[1])) {
158         $definition['fields'][$name]['size'] = $size;
159       }
160       if (isset($matches[2]) && $type === 'numeric') {
161         // Add precision and scale.
162         $definition['fields'][$name]['precision'] = $matches[2];
163         $definition['fields'][$name]['scale'] = $matches[3];
164       }
165       elseif ($type === 'time' || $type === 'datetime') {
166         // @todo Core doesn't support these, but copied from `migrate-db.sh` for now.
167         // Convert to varchar.
168         $definition['fields'][$name]['type'] = 'varchar';
169         $definition['fields'][$name]['length'] = '100';
170       }
171       elseif (!isset($definition['fields'][$name]['size'])) {
172         // Try use the provided length, if it doesn't exist default to 100. It's
173         // not great but good enough for our dumps at this point.
174         $definition['fields'][$name]['length'] = isset($matches[2]) ? $matches[2] : 100;
175       }
176
177       if (isset($row['Default'])) {
178         $definition['fields'][$name]['default'] = $row['Default'];
179       }
180
181       if (isset($matches[4])) {
182         $definition['fields'][$name]['unsigned'] = TRUE;
183       }
184
185       // Check for the 'varchar_ascii' type that should be 'binary'.
186       if (isset($row['Collation']) && $row['Collation'] == 'ascii_bin') {
187         $definition['fields'][$name]['type'] = 'varchar_ascii';
188         $definition['fields'][$name]['binary'] = TRUE;
189       }
190
191       // Check for the non-binary 'varchar_ascii'.
192       if (isset($row['Collation']) && $row['Collation'] == 'ascii_general_ci') {
193         $definition['fields'][$name]['type'] = 'varchar_ascii';
194       }
195
196       // Check for the 'utf8_bin' collation.
197       if (isset($row['Collation']) && $row['Collation'] == 'utf8_bin') {
198         $definition['fields'][$name]['binary'] = TRUE;
199       }
200     }
201
202     // Set primary key, unique keys, and indexes.
203     $this->getTableIndexes($connection, $table, $definition);
204
205     // Set table collation.
206     $this->getTableCollation($connection, $table, $definition);
207
208     return $definition;
209   }
210
211   /**
212    * Adds primary key, unique keys, and index information to the schema.
213    *
214    * @param \Drupal\Core\Database\Connection $connection
215    *   The database connection to use.
216    * @param string $table
217    *   The table to find indexes for.
218    * @param array &$definition
219    *   The schema definition to modify.
220    */
221   protected function getTableIndexes(Connection $connection, $table, &$definition) {
222     // Note, this query doesn't support ordering, so that is worked around
223     // below by keying the array on Seq_in_index.
224     $query = $connection->query("SHOW INDEX FROM {" . $table . "}");
225     while (($row = $query->fetchAssoc()) !== FALSE) {
226       $index_name = $row['Key_name'];
227       $column = $row['Column_name'];
228       // Key the arrays by the index sequence for proper ordering (start at 0).
229       $order = $row['Seq_in_index'] - 1;
230
231       // If specified, add length to the index.
232       if ($row['Sub_part']) {
233         $column = [$column, $row['Sub_part']];
234       }
235
236       if ($index_name === 'PRIMARY') {
237         $definition['primary key'][$order] = $column;
238       }
239       elseif ($row['Non_unique'] == 0) {
240         $definition['unique keys'][$index_name][$order] = $column;
241       }
242       else {
243         $definition['indexes'][$index_name][$order] = $column;
244       }
245     }
246   }
247
248   /**
249    * Set the table collation.
250    *
251    * @param \Drupal\Core\Database\Connection $connection
252    *   The database connection to use.
253    * @param string $table
254    *   The table to find indexes for.
255    * @param array &$definition
256    *   The schema definition to modify.
257    */
258   protected function getTableCollation(Connection $connection, $table, &$definition) {
259     $query = $connection->query("SHOW TABLE STATUS LIKE '{" . $table . "}'");
260     $data = $query->fetchAssoc();
261
262     // Map the collation to a character set. For example, 'utf8mb4_general_ci'
263     // (MySQL 5) or 'utf8mb4_0900_ai_ci' (MySQL 8) will be mapped to 'utf8mb4'.
264     list($charset,) = explode('_', $data['Collation'], 2);
265
266     // Set `mysql_character_set`. This will be ignored by other backends.
267     $definition['mysql_character_set'] = $charset;
268   }
269
270   /**
271    * Gets all data from a given table.
272    *
273    * If a table is set to be schema only, and empty array is returned.
274    *
275    * @param \Drupal\Core\Database\Connection $connection
276    *   The database connection to use.
277    * @param string $table
278    *   The table to query.
279    *
280    * @return array
281    *   The data from the table as an array.
282    */
283   protected function getTableData(Connection $connection, $table) {
284     $order = $this->getFieldOrder($connection, $table);
285     $query = $connection->query("SELECT * FROM {" . $table . "} " . $order);
286     $results = [];
287     while (($row = $query->fetchAssoc()) !== FALSE) {
288       $results[] = $row;
289     }
290     return $results;
291   }
292
293   /**
294    * Given a database field type, return a Drupal type.
295    *
296    * @param \Drupal\Core\Database\Connection $connection
297    *   The database connection to use.
298    * @param string $type
299    *   The MySQL field type.
300    *
301    * @return string
302    *   The Drupal schema field type. If there is no mapping, the original field
303    *   type is returned.
304    */
305   protected function fieldTypeMap(Connection $connection, $type) {
306     // Convert everything to lowercase.
307     $map = array_map('strtolower', $connection->schema()->getFieldTypeMap());
308     $map = array_flip($map);
309
310     // The MySql map contains type:size. Remove the size part.
311     return isset($map[$type]) ? explode(':', $map[$type])[0] : $type;
312   }
313
314   /**
315    * Given a database field type, return a Drupal size.
316    *
317    * @param \Drupal\Core\Database\Connection $connection
318    *   The database connection to use.
319    * @param string $type
320    *   The MySQL field type.
321    *
322    * @return string
323    *   The Drupal schema field size.
324    */
325   protected function fieldSizeMap(Connection $connection, $type) {
326     // Convert everything to lowercase.
327     $map = array_map('strtolower', $connection->schema()->getFieldTypeMap());
328     $map = array_flip($map);
329
330     $schema_type = explode(':', $map[$type])[0];
331     // Only specify size on these types.
332     if (in_array($schema_type, ['blob', 'float', 'int', 'text'])) {
333       // The MySql map contains type:size. Remove the type part.
334       return explode(':', $map[$type])[1];
335     }
336   }
337
338   /**
339    * Gets field ordering for a given table.
340    *
341    * @param \Drupal\Core\Database\Connection $connection
342    *   The database connection to use.
343    * @param string $table
344    *   The table name.
345    *
346    * @return string
347    *   The order string to append to the query.
348    */
349   protected function getFieldOrder(Connection $connection, $table) {
350     // @todo this is MySQL only since there are no Database API functions for
351     // table column data.
352     // @todo this code is duplicated in `core/scripts/migrate-db.sh`.
353     $connection_info = $connection->getConnectionOptions();
354     // Order by primary keys.
355     $order = '';
356     $query = "SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS`
357     WHERE (`TABLE_SCHEMA` = '" . $connection_info['database'] . "')
358     AND (`TABLE_NAME` = '{" . $table . "}') AND (`COLUMN_KEY` = 'PRI')
359     ORDER BY COLUMN_NAME";
360     $results = $connection->query($query);
361     while (($row = $results->fetchAssoc()) !== FALSE) {
362       $order .= $row['COLUMN_NAME'] . ', ';
363     }
364     if (!empty($order)) {
365       $order = ' ORDER BY ' . rtrim($order, ', ');
366     }
367     return $order;
368   }
369
370   /**
371    * The script template.
372    *
373    * @return string
374    *   The template for the generated PHP script.
375    */
376   protected function getTemplate() {
377     // The template contains an instruction for the file to be ignored by PHPCS.
378     // This is because the files can be huge and coding standards are
379     // irrelevant.
380     $script = <<<'ENDOFSCRIPT'
381 <?php
382 // @codingStandardsIgnoreFile
383 /**
384  * @file
385  * A database agnostic dump for testing purposes.
386  *
387  * This file was generated by the Drupal 8.0 db-tools.php script.
388  */
389
390 use Drupal\Core\Database\Database;
391
392 $connection = Database::getConnection();
393
394 {{TABLES}}
395
396 ENDOFSCRIPT;
397     return $script;
398   }
399
400   /**
401    * The part of the script for each table.
402    *
403    * @param string $table
404    *   Table name.
405    * @param array $schema
406    *   Drupal schema definition.
407    * @param array $data
408    *   Data for the table.
409    *
410    * @return string
411    *   The table create statement, and if there is data, the insert command.
412    */
413   protected function getTableScript($table, array $schema, array $data) {
414     $output = '';
415     $output .= "\$connection->schema()->createTable('" . $table . "', " . Variable::export($schema) . ");\n\n";
416     if (!empty($data)) {
417       $insert = '';
418       foreach ($data as $record) {
419         $insert .= "->values(" . Variable::export($record) . ")\n";
420       }
421       $output .= "\$connection->insert('" . $table . "')\n"
422         . "->fields(" . Variable::export(array_keys($schema['fields'])) . ")\n"
423         . $insert
424         . "->execute();\n\n";
425     }
426     return $output;
427   }
428
429 }