Security update for Core, with self-updated composer
[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     // Set `mysql_character_set`. This will be ignored by other backends.
263     $definition['mysql_character_set'] = str_replace('_general_ci', '', $data['Collation']);
264   }
265
266   /**
267    * Gets all data from a given table.
268    *
269    * If a table is set to be schema only, and empty array is returned.
270    *
271    * @param \Drupal\Core\Database\Connection $connection
272    *   The database connection to use.
273    * @param string $table
274    *   The table to query.
275    *
276    * @return array
277    *   The data from the table as an array.
278    */
279   protected function getTableData(Connection $connection, $table) {
280     $order = $this->getFieldOrder($connection, $table);
281     $query = $connection->query("SELECT * FROM {" . $table . "} " . $order);
282     $results = [];
283     while (($row = $query->fetchAssoc()) !== FALSE) {
284       $results[] = $row;
285     }
286     return $results;
287   }
288
289   /**
290    * Given a database field type, return a Drupal type.
291    *
292    * @param \Drupal\Core\Database\Connection $connection
293    *   The database connection to use.
294    * @param string $type
295    *   The MySQL field type.
296    *
297    * @return string
298    *   The Drupal schema field type. If there is no mapping, the original field
299    *   type is returned.
300    */
301   protected function fieldTypeMap(Connection $connection, $type) {
302     // Convert everything to lowercase.
303     $map = array_map('strtolower', $connection->schema()->getFieldTypeMap());
304     $map = array_flip($map);
305
306     // The MySql map contains type:size. Remove the size part.
307     return isset($map[$type]) ? explode(':', $map[$type])[0] : $type;
308   }
309
310   /**
311    * Given a database field type, return a Drupal size.
312    *
313    * @param \Drupal\Core\Database\Connection $connection
314    *   The database connection to use.
315    * @param string $type
316    *   The MySQL field type.
317    *
318    * @return string
319    *   The Drupal schema field size.
320    */
321   protected function fieldSizeMap(Connection $connection, $type) {
322     // Convert everything to lowercase.
323     $map = array_map('strtolower', $connection->schema()->getFieldTypeMap());
324     $map = array_flip($map);
325
326     $schema_type = explode(':', $map[$type])[0];
327     // Only specify size on these types.
328     if (in_array($schema_type, ['blob', 'float', 'int', 'text'])) {
329       // The MySql map contains type:size. Remove the type part.
330       return explode(':', $map[$type])[1];
331     }
332   }
333
334   /**
335    * Gets field ordering for a given table.
336    *
337    * @param \Drupal\Core\Database\Connection $connection
338    *   The database connection to use.
339    * @param string $table
340    *   The table name.
341    *
342    * @return string
343    *   The order string to append to the query.
344    */
345   protected function getFieldOrder(Connection $connection, $table) {
346     // @todo this is MySQL only since there are no Database API functions for
347     // table column data.
348     // @todo this code is duplicated in `core/scripts/migrate-db.sh`.
349     $connection_info = $connection->getConnectionOptions();
350     // Order by primary keys.
351     $order = '';
352     $query = "SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS`
353     WHERE (`TABLE_SCHEMA` = '" . $connection_info['database'] . "')
354     AND (`TABLE_NAME` = '{" . $table . "}') AND (`COLUMN_KEY` = 'PRI')
355     ORDER BY COLUMN_NAME";
356     $results = $connection->query($query);
357     while (($row = $results->fetchAssoc()) !== FALSE) {
358       $order .= $row['COLUMN_NAME'] . ', ';
359     }
360     if (!empty($order)) {
361       $order = ' ORDER BY ' . rtrim($order, ', ');
362     }
363     return $order;
364   }
365
366   /**
367    * The script template.
368    *
369    * @return string
370    *   The template for the generated PHP script.
371    */
372   protected function getTemplate() {
373     // The template contains an instruction for the file to be ignored by PHPCS.
374     // This is because the files can be huge and coding standards are
375     // irrelevant.
376     $script = <<<'ENDOFSCRIPT'
377 <?php
378 // @codingStandardsIgnoreFile
379 /**
380  * @file
381  * A database agnostic dump for testing purposes.
382  *
383  * This file was generated by the Drupal 8.0 db-tools.php script.
384  */
385
386 use Drupal\Core\Database\Database;
387
388 $connection = Database::getConnection();
389
390 {{TABLES}}
391
392 ENDOFSCRIPT;
393     return $script;
394   }
395
396   /**
397    * The part of the script for each table.
398    *
399    * @param string $table
400    *   Table name.
401    * @param array $schema
402    *   Drupal schema definition.
403    * @param array $data
404    *   Data for the table.
405    *
406    * @return string
407    *   The table create statement, and if there is data, the insert command.
408    */
409   protected function getTableScript($table, array $schema, array $data) {
410     $output = '';
411     $output .= "\$connection->schema()->createTable('" . $table . "', " . Variable::export($schema) . ");\n\n";
412     if (!empty($data)) {
413       $insert = '';
414       foreach ($data as $record) {
415         $insert .= "->values(" . Variable::export($record) . ")\n";
416       }
417       $output .= "\$connection->insert('" . $table . "')\n"
418         . "->fields(" . Variable::export(array_keys($schema['fields'])) . ")\n"
419         . $insert
420         . "->execute();\n\n";
421     }
422     return $output;
423   }
424
425 }