Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.76% covered (success)
91.76%
156 / 170
75.00% covered (warning)
75.00%
15 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQL_Generator
91.76% covered (success)
91.76%
156 / 170
75.00% covered (warning)
75.00%
15 / 20
69.51
0.00% covered (danger)
0.00%
0 / 1
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 comment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 output
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 var_assignment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 var
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 start
81.40% covered (warning)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
7.32
 end
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 start_table
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 start_table_inserts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 table_insert
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 end_table_inserts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_index_name
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_column
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
15.05
 get_dump
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_current_table
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 start_table_creation
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 end_table_creation
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 reset
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2/**
3 * SQL_Generator file.
4 *
5 * @package wpcomsh
6 */
7
8namespace Imports;
9
10use WP_Error;
11
12/**
13 * Generate a SQL file from metadata.
14 */
15class SQL_Generator {
16    const OUTPUT_TYPE_FILE   = 0;
17    const OUTPUT_TYPE_STRING = 1;
18    const OUTPUT_TYPE_STDOUT = 2;
19
20    const DEFAULT_CHARSET   = 'utf8mb4';
21    const DEFAULT_COLLATION = 'utf8mb4_unicode_520_ci';
22
23    /**
24     * The output type.
25     *
26     * @var int
27     */
28    private int $output_mode = self::OUTPUT_TYPE_STRING;
29
30    /**
31     * The output file.
32     *
33     * @var string|resource|null
34     */
35    private $output_handle = '';
36
37    /**
38     * The current table.
39     *
40     * @var ?string
41     */
42    private $current_table = null;
43
44    /**
45     * The current insert index.
46     *
47     * @var int
48     */
49    private $current_insert_index = 0;
50
51    /**
52     * Whether the dump has started.
53     *
54     * @var bool
55     */
56    private $started = false;
57
58    /**
59     * Whether to use transactions.
60     *
61     * @var bool
62     */
63    private $transaction = true;
64
65    /**
66     * The collation.
67     *
68     * @var ?string
69     */
70    private $collation = null;
71
72    /**
73     * The collation.
74     *
75     * @var string
76     */
77    private $charset = self::DEFAULT_CHARSET;
78
79    /**
80     * SQL_Generator destructor.
81     */
82    public function __destruct() {
83        $this->reset();
84    }
85
86    /**
87     * MySQL `--` comment.
88     *
89     * @param string $comment The comment.
90     *
91     * @return void
92     */
93    public function comment( string $comment ) {
94        $this->output( "-- {$comment}" );
95    }
96
97    /**
98     * An empty line.
99     *
100     * @return void
101     */
102    public function nl() {
103        $this->output();
104    }
105
106    /**
107     * An SQL query.
108     *
109     * @param string $query The query.
110     * @param bool   $nl    Whether to add a new line at the end.
111     *
112     * @return void
113     */
114    public function output( string $query = '', $nl = true ) {
115        if ( $nl ) {
116            $query .= "\n";
117        }
118
119        if ( $this->output_mode === self::OUTPUT_TYPE_STDOUT ) {
120            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
121            echo $query;
122        } elseif ( $this->output_mode === self::OUTPUT_TYPE_STRING ) {
123            $this->output_handle .= $query;
124        } elseif ( $this->output_mode === self::OUTPUT_TYPE_FILE ) {
125            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
126            fwrite( $this->output_handle, $query );
127        }
128    }
129
130    /**
131     * A variable assignment.
132     *
133     * @param string $variable The variable name.
134     * @param string $value    The variable value.
135     * @param int    $version  The MySQL version when the variable was introduced.
136     *
137     * @return void
138     *
139     * @see https://dev.mysql.com/doc/refman/8.2/en/set-variable.html
140     */
141    public function var_assignment( string $variable, string $value, int $version = 40101 ) {
142        $this->output( "/*!{$version} SET {$variable}={$value} */;" );
143    }
144
145    /**
146     * A SET statement.
147     *
148     * @param string $content The SET statement content.
149     * @param int    $version The MySQL version when the statement was introduced.
150     *
151     * @see https://dev.mysql.com/doc/refman/8.2/en/set-statement.html
152     */
153    public function var( string $content, int $version = 40101 ) {
154        $this->output( "/*!{$version} {$content} */;" );
155    }
156
157    /**
158     * A three-line comment header.
159     *
160     * @param string $title The title.
161     *
162     * @return void
163     */
164    public function header( $title ) {
165        $this->comment( '' );
166        $this->comment( $title );
167        $this->comment( '' );
168        $this->nl();
169    }
170
171    /**
172     * Start the dump. This should be called before any other methods.
173     *
174     * @param array $options The options.
175     *
176     * @return bool|WP_Error
177     */
178    public function start( $options = array() ) {
179        $defaults = array(
180            'charset'     => self::DEFAULT_CHARSET,
181            'collation'   => null,
182            'output_file' => null,
183            'output_mode' => self::OUTPUT_TYPE_STRING,
184            'transaction' => true,
185        );
186
187        $options = wp_parse_args( $options, $defaults );
188
189        // Reset the output.
190        $this->reset();
191
192        $this->output_mode = $options['output_mode'];
193
194        if ( $this->output_mode === self::OUTPUT_TYPE_STDOUT ) {
195            $this->output_handle = null;
196        } elseif ( $this->output_mode === self::OUTPUT_TYPE_STRING ) {
197            $this->output_handle = '';
198        } elseif ( $this->output_mode === self::OUTPUT_TYPE_FILE ) {
199            if ( is_dir( $options['output_file'] ) ) {
200                return new WP_Error( 'output-open-error', 'Output file is a directory.' );
201            }
202
203            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
204            $file_handle = fopen( $options['output_file'], 'a' );
205
206            if ( $file_handle === false ) {
207                return new WP_Error( 'output-open-error', 'Error opening output file.' );
208            }
209
210            $this->output_handle = $file_handle;
211        }
212
213        $this->started     = true;
214        $this->transaction = $options['transaction'];
215        $this->collation   = $options['collation'];
216        $this->charset     = $options['charset'];
217
218        // Start the dump header.
219        $this->header( 'Playground SQLite MySQL dump' );
220
221        $this->output( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";' );
222
223        if ( $this->transaction ) {
224            $this->output( 'START TRANSACTION;' );
225        }
226
227        $this->output( 'SET time_zone = "+00:00";' );
228        $this->nl();
229
230        // Various MySQL settings.
231        $this->var_assignment( '@OLD_CHARACTER_SET_CLIENT', '@@CHARACTER_SET_CLIENT' );
232        $this->var_assignment( '@OLD_CHARACTER_SET_RESULTS', '@@CHARACTER_SET_RESULTS' );
233        $this->var_assignment( '@OLD_COLLATION_CONNECTION', '@@COLLATION_CONNECTION ' );
234        $this->var( 'SET NAMES ' . $this->charset );
235        $this->var_assignment( '@OLD_TIME_ZONE', '@@TIME_ZONE', 40103 );
236        $this->var_assignment( 'TIME_ZONE', '\'+00:00\'', 40103 );
237        $this->var_assignment( '@OLD_UNIQUE_CHECKS', '@@UNIQUE_CHECKS, UNIQUE_CHECKS=0', 40014 );
238        $this->var_assignment( '@OLD_FOREIGN_KEY_CHECKS', '@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0', 40014 );
239        $this->var_assignment( '@OLD_SQL_MODE', '@@SQL_MODE, SQL_MODE=\'NO_AUTO_VALUE_ON_ZERO\'' );
240        $this->var_assignment( '@OLD_SQL_NOTES', '@@SQL_NOTES, SQL_NOTES=0', 40111 );
241        $this->nl();
242
243        return true;
244    }
245
246    /**
247     * End the dump. This should be called after all other methods.
248     */
249    public function end() {
250        // Not started, return.
251        if ( ! $this->started ) {
252            return;
253        }
254
255        if ( $this->transaction ) {
256            // End transaction.
257            $this->output( 'COMMIT;' );
258        }
259
260        // Various MySQL settings.
261        $this->var_assignment( 'TIME_ZONE', '@OLD_TIME_ZONE', 40103 );
262        $this->var_assignment( 'SQL_MODE', '@OLD_SQL_MODE' );
263        $this->var_assignment( 'FOREIGN_KEY_CHECKS', '@OLD_FOREIGN_KEY_CHECKS', 40014 );
264        $this->var_assignment( 'UNIQUE_CHECKS', '@OLD_UNIQUE_CHECKS', 40014 );
265        $this->var_assignment( 'CHARACTER_SET_CLIENT', '@OLD_CHARACTER_SET_CLIENT' );
266        $this->var_assignment( 'CHARACTER_SET_RESULTS', '@OLD_CHARACTER_SET_RESULTS' );
267        $this->var_assignment( 'COLLATION_CONNECTION', '@OLD_COLLATION_CONNECTION' );
268        $this->var_assignment( 'SQL_NOTES', '@OLD_SQL_NOTES', 40111 );
269        $this->nl();
270        $this->comment( 'Dump completed on ' . gmdate( 'Y-m-d H:i:s' ) );
271    }
272
273    /**
274     * Create a table.
275     *
276     * @param string $table_name     The table name.
277     * @param array  $types_map      The types map.
278     * @param int    $auto_increment The auto increment value.
279     * @param bool   $add_drop       Whether to add a DROP TABLE statement.
280     *
281     * @return void
282     */
283    public function start_table( string $table_name, array $types_map, int $auto_increment, bool $add_drop = true ) {
284        $this->start_table_creation( $table_name, $add_drop );
285
286        $indexes      = array();
287        $columns      = array();
288        $primary_keys = array();
289
290        // Output the columns.
291        foreach ( $types_map as $column ) {
292            if ( $column['type'] === 'UNIQUE' || $column['type'] === 'KEY' ) {
293                $indexes[] = $column;
294
295                continue;
296            }
297
298            $columns[] = $this->get_column( $column['name'], $column );
299
300            // Save up the PRIMARY column for later.
301            if ( $column['primary'] ) {
302                $primary_keys[] = '`' . $column['name'] . '`';
303                continue;
304            }
305        }
306
307        if ( count( $primary_keys ) ) {
308            // Add the PRIMARY KEY
309            $columns[] = 'PRIMARY KEY (' . implode( ',', $primary_keys ) . ')';
310        }
311
312        foreach ( $indexes as $index ) {
313            $index_list = $index['columns'];
314            $key_type   = $index['type'] === 'UNIQUE' ? 'UNIQUE KEY' : 'KEY';
315
316            // Output the keys.
317            $columns[] = "{$key_type} `{$index['name']}{$index_list}";
318        }
319
320        if ( count( $columns ) > 0 ) {
321            $columns[0] = '  ' . $columns[0];
322            $this->output( implode( ",\n  ", $columns ) );
323        }
324
325        $this->end_table_creation( $table_name, $auto_increment );
326    }
327
328    /**
329     * Start table inserts. Disable keys.
330     *
331     * @return void
332     */
333    public function start_table_inserts() {
334        if ( ! $this->current_table ) {
335            return;
336        }
337
338        $this->header( "Dumping data for table `{$this->current_table}`" );
339        $this->output( "LOCK TABLES `{$this->current_table}` WRITE;" );
340        $this->var( "ALTER TABLE `{$this->current_table}` DISABLE KEYS", 40000 );
341
342        $this->current_insert_index = 0;
343    }
344
345    /**
346     * Generate an INSERT statement.
347     *
348     * @param string $field_names The field names.
349     * @param string $data        The data.
350     *
351     * @return void
352     */
353    public function table_insert( string $field_names, string $data ) {
354        if ( ! $this->current_table ) {
355            return;
356        }
357
358        if ( $this->current_insert_index === 0 ) {
359            $this->output( "INSERT INTO `{$this->current_table}{$field_names} VALUES" );
360        } else {
361            $this->output( ',' );
362        }
363
364        $this->output( $data, false );
365
366        ++$this->current_insert_index;
367    }
368
369    /**
370     * End table inserts. Enable keys.
371     *
372     * @return void
373     */
374    public function end_table_inserts() {
375        if ( ! $this->current_table ) {
376            return;
377        }
378
379        if ( $this->current_insert_index > 0 ) {
380            $this->output( ';' );
381        }
382
383        $this->var( "ALTER TABLE `{$this->current_table}` ENABLE KEYS", 40000 );
384        $this->output( 'UNLOCK TABLES;' );
385        $this->nl();
386
387        $this->current_insert_index = 0;
388    }
389
390    /**
391     * Get the real index name, from the SQLite index name.
392     *
393     * @param string $index_name The index name.
394     *
395     * @return string
396     */
397    public static function get_index_name( string $index_name ): string {
398        if ( strlen( $index_name ) < 4 ) {
399            return '';
400        }
401
402        $real_names = explode( '__', $index_name );
403
404        if ( count( $real_names ) === 2 ) {
405            return $real_names[1];
406        }
407
408        return '';
409    }
410
411    /**
412     * Get a column definition for the CREATE statement.
413     *
414     * @param string $name   The column name.
415     * @param array  $column The column definition.
416     *
417     * @return string
418     */
419    public function get_column( string $name, array $column ): string {
420        if ( $name === '' || ! array_key_exists( 'type', $column ) || ! array_key_exists( 'sqlite_type', $column ) ) {
421            return '';
422        }
423
424        $ret = "`{$name}{$column['type']}";
425
426        if ( null !== $this->collation && $column['sqlite_type'] === 'text' && $column['type'] !== 'datetime' ) {
427            $ret .= ' COLLATE ' . $this->collation;
428        }
429
430        if ( array_key_exists( 'not_null', $column ) && $column['not_null'] ) {
431            $ret .= ' NOT NULL';
432        }
433
434        $is_auto_increment = array_key_exists( 'auto_increment', $column ) && $column['auto_increment'];
435
436        if ( $is_auto_increment ) {
437            $ret .= ' AUTO_INCREMENT';
438        }
439
440        if ( ! $is_auto_increment && array_key_exists( 'default', $column ) && $column['default'] !== null ) {
441            $default = $column['default'];
442
443            if ( $column['sqlite_type'] === 'integer' ) {
444                // Force an integer. The default value is a string.
445                $default = (int) $default;
446            }
447
448            $ret .= ' DEFAULT ' . $default;
449        }
450
451        return $ret;
452    }
453
454    /**
455     * Get the dump.
456     *
457     * @return string
458     */
459    public function get_dump(): string {
460        if ( $this->output_mode === self::OUTPUT_TYPE_STRING ) {
461            return $this->output_handle;
462        }
463
464        return '';
465    }
466
467    /**
468     * Get current table.
469     *
470     * @return string|null
471     */
472    public function get_current_table(): ?string {
473        return $this->current_table;
474    }
475
476    /**
477     * Start a table.
478     *
479     * @param string $table_name The table name.
480     * @param bool   $add_drop   Whether to add a DROP TABLE statement.
481     *
482     * @return void
483     */
484    private function start_table_creation( string $table_name, bool $add_drop = true ) {
485        $this->current_table = $table_name;
486        $this->header( "Table structure for table `{$table_name}`" );
487
488        if ( $add_drop ) {
489            $this->output( "DROP TABLE IF EXISTS `{$table_name}`;" );
490        }
491
492        $this->var_assignment( '@saved_cs_client', '@@character_set_client' );
493        $this->var_assignment( 'character_set_client', 'utf8' );
494        $this->output( "CREATE TABLE `{$table_name}` (" );
495    }
496
497    /**
498     * End table creation.
499     *
500     * @param string $table_name     The table name.
501     * @param int    $auto_increment The auto increment value.
502     *
503     * @return void
504     */
505    private function end_table_creation( string $table_name, int $auto_increment ) {
506        $end = ') ENGINE=InnoDB';
507
508        if ( $auto_increment ) {
509            $end .= ' AUTO_INCREMENT=' . ( $auto_increment + 1 );
510        }
511
512        $end .= ' CHARSET=' . $this->charset;
513
514        if ( null !== $this->collation ) {
515            $end .= ' COLLATE=' . $this->collation;
516        }
517
518        $this->output( $end . ';' );
519
520        $this->var_assignment( 'character_set_client', '@saved_cs_client' );
521        $this->nl();
522
523        $this->start_table_inserts();
524    }
525
526    /**
527     * Reset the generator.
528     */
529    private function reset() {
530        if ( $this->output_mode === self::OUTPUT_TYPE_FILE && $this->output_handle ) {
531            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
532            fclose( $this->output_handle );
533        }
534
535        $this->charset              = self::DEFAULT_CHARSET;
536        $this->collation            = null;
537        $this->current_insert_index = 0;
538        $this->current_table        = null;
539        $this->output_handle        = '';
540        $this->output_mode          = self::OUTPUT_TYPE_STRING;
541        $this->started              = false;
542        $this->transaction          = true;
543    }
544}