Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.12% covered (warning)
84.12%
196 / 233
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playground_DB_Importer
84.48% covered (warning)
84.48%
196 / 232
64.29% covered (warning)
64.29%
9 / 14
99.15
0.00% covered (danger)
0.00%
0 / 1
 generate_sql
78.12% covered (warning)
78.12%
25 / 32
0.00% covered (danger)
0.00%
0 / 1
9.85
 parse_database
73.17% covered (warning)
73.17%
30 / 41
0.00% covered (danger)
0.00%
0 / 1
22.58
 check_database_integrity
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 generate_inserts
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get_input_table_name
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_output_table_name
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_table_types_map
81.71% covered (warning)
81.71%
67 / 82
0.00% covered (danger)
0.00%
0 / 1
19.98
 sqlite_type_to_format
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
7.29
 needs_191_limit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_table_autoincrement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_tmp_file_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hot_fix_missing_indexes
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 is_valid_table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Playground_DB_Importer file.
4 *
5 * @package wpcomsh
6 */
7
8namespace Imports;
9
10require_once __DIR__ . '/class-sql-generator.php';
11
12use SQLite3;
13use WP_Error;
14
15/**
16 * Parses a Playground SQLite database file and generate a SQL file.
17 */
18class Playground_DB_Importer {
19    /**
20     * Name of the table where SQLite map the internal types to the MySQL types.
21     */
22    public const SQLITE_DATA_TYPES_TABLE = '_wp_sqlite_mysql_information_schema_columns';
23
24    /**
25     * Name of the table where SQLite store the autoincrement value.
26     */
27    public const SQLITE_SEQUENCE_TABLE = 'sqlite_sequence';
28
29    /**
30     * The database connection.
31     *
32     * @var ?SQLite3
33     */
34    private ?SQLite3 $db;
35
36    /**
37     * The options.
38     *
39     * @var array
40     */
41    private array $options = array();
42
43    /**
44     * Generate a .sql file from a Playground SQLite database file.
45     *
46     * @param string $database_file_path The database file path.
47     * @param array  $options {
48     *     An array of options.
49     *
50     *     @type bool   $all_tables     Generate all tables, not only core. Defaults to true.
51     *     @type string $charset        The charset to use for the generated SQL. Defaults to 'latin1'.
52     *     @type string $collation      The collation to use for the generated SQL. Defaults to null.
53     *     @type array  $exclude_tables A list of tables to exclude from the generated SQL.
54     *     @type string $output_file    The output file path. If not set, a temporary file will be used.
55     *     @type int    $output_mode    Output mode. Defaults to SQL_Generator::OUTPUT_TYPE_STRING.
56     *     @type string $output_prefix  The generated tables prefix.
57     *     @type string $prefix         The input tables prefix. (Always `wp_` for Playground databases)
58     *     @type string $tmp_prefix     The temporary tables prefix.
59     *     @type bool   $tmp_tables     Whether to generate temporary tables instead of TRANSACTION. Defaults to false.
60     * }
61     *
62     * @return string|WP_Error
63     */
64    public function generate_sql( string $database_file_path, $options = array() ) {
65        global $wpdb;
66
67        $defaults = array(
68            'all_tables'     => true,
69            'charset'        => 'latin1',
70            'collation'      => null,
71            'exclude_tables' => array(),
72            'output_file'    => null,
73            'output_mode'    => SQL_Generator::OUTPUT_TYPE_STRING,
74            'output_prefix'  => $wpdb->prefix,
75            'prefix'         => 'wp_',
76            'tmp_prefix'     => 'tmp_',
77            'tmp_tables'     => false,
78        );
79
80        $this->options = wp_parse_args( $options, $defaults );
81
82        // Bail if the file doesn't exist.
83        if ( ! is_file( $database_file_path ) || ! is_readable( $database_file_path ) ) {
84            return new WP_Error( 'database-file-not-exists', 'Database file not exists' );
85        }
86
87        // Bail if the file is empty.
88        if ( filesize( $database_file_path ) <= 0 ) {
89            return new WP_Error( 'database-file-empty', 'Database file is empty' );
90        }
91
92        // Set the output file.
93        if ( $this->options['output_mode'] === SQL_Generator::OUTPUT_TYPE_FILE ) {
94            // If the output file is not set, then use a temporary file.
95            if ( empty( $this->options['output_file'] ) ) {
96                $this->options['output_file'] = trailingslashit( sys_get_temp_dir() ) . $this->get_tmp_file_name();
97            }
98        } else {
99            $this->options['output_file'] = null;
100        }
101
102        try {
103            // Try to open the database file.
104            $this->db = new SQLite3( $database_file_path, SQLITE3_OPEN_READONLY );
105
106            $ret = $this->parse_database();
107
108            $this->db->close();
109
110            if ( is_wp_error( $ret ) ) {
111                return $ret;
112            }
113
114            // Return the file path if the output mode is file.
115            if ( $this->options['output_mode'] === SQL_Generator::OUTPUT_TYPE_FILE ) {
116                return $this->options['output_file'];
117            }
118
119            // Return the SQL string.
120            return $ret;
121        } catch ( \Exception $e ) {
122            $this->db = null;
123
124            return new WP_Error( 'sqlite-open-error', $e->getMessage() );
125        }
126    }
127
128    /**
129     * Parse the database and load data.
130     *
131     * @return string|WP_Error
132     */
133    private function parse_database() {
134        global $wpdb;
135
136        if ( ! $this->db ) {
137            return new WP_Error( 'no-database-connection', 'No database connection.' );
138        }
139
140        // Check if the bind table and the sequence table exist.
141        $valid_db = $this->check_database_integrity();
142
143        if ( is_wp_error( $valid_db ) ) {
144            return $valid_db;
145        }
146
147        $core_tables = array_flip( $wpdb->tables );
148        $results     = $this->db->query( 'SELECT name FROM sqlite_master WHERE type=\'table\'' );
149
150        if ( ! $results ) {
151            return new WP_Error( 'no-database-master-schema', 'Query error: can\'t read database schema.' );
152        }
153
154        $generator = new SQL_Generator();
155        $excluded  = is_array( $this->options['exclude_tables'] ) ? $this->options['exclude_tables'] : array();
156
157        if ( $this->options['tmp_tables'] ) {
158            $this->options['transaction'] = false;
159        }
160
161        $started = $generator->start( $this->options );
162
163        if ( is_wp_error( $started ) ) {
164            return $started;
165        }
166
167        // Check if the table exists in the core tables list.
168        while ( $table = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
169            $table_name = $this->get_input_table_name( $table['name'] );
170
171            // This is not a core table. Skip if all tables needed.
172            if ( ! $this->options['all_tables'] && ! array_key_exists( $table_name, $core_tables ) ) {
173                continue;
174            }
175
176            // SQLite internal tables. Skip.
177            if ( ! $this->is_valid_table( $table_name ) ) {
178                continue;
179            }
180
181            // An excluded table. Skip.
182            if ( in_array( $table_name, $excluded, true ) ) {
183                // Remove the table from the excluded list.
184                unset( $excluded[ $table_name ] );
185
186                continue;
187            }
188
189            // Get the type map.
190            $types_map = $this->get_table_types_map( $table['name'] );
191
192            if ( is_wp_error( $types_map ) ) {
193                return $types_map;
194            } elseif ( empty( $types_map['map'] ) ) {
195                continue;
196            }
197
198            // Force a temporary table name if needed.
199            $output_table = $this->get_output_table_name( $table_name );
200
201            $generator->start_table( $output_table, $types_map['map'], $types_map['auto_increment'] );
202            $this->generate_inserts( $generator, $table['name'], $types_map['format'], $types_map['field_names'] );
203            $generator->end_table_inserts();
204
205            if ( ! $this->options['all_tables'] ) {
206                // Remove the table from the core tables list.
207                unset( $core_tables[ $table_name ] );
208            }
209        }
210
211        if ( ! $this->options['all_tables'] ) {
212            // If the core tables list is not empty, then there are missing tables.
213            if ( ! empty( $core_tables ) ) {
214                return new WP_Error( 'missing-tables', 'Query error: missing tables.' );
215            }
216        }
217
218        $generator->end();
219
220        // Found all the tables, return the SQL.
221        return $generator->get_dump();
222    }
223
224    /**
225     * Check the database integrity.
226     *
227     * The `_mysql_data_types_cache` and `sqlite_sequence` tables must exist.
228     *
229     * @return bool|WP_Error
230     */
231    private function check_database_integrity() {
232        // Check if the bind table and the sequence table exist.
233        $query = $this->prepare(
234            'SELECT COUNT(*) FROM sqlite_master WHERE type=%s AND (name=%s OR name=%s)',
235            'table',
236            self::SQLITE_DATA_TYPES_TABLE,
237            self::SQLITE_SEQUENCE_TABLE
238        );
239        $count = $this->db->querySingle( $query );
240
241        if ( $count !== 2 ) {
242            // Not a real WordPress SQLite database.
243            return new WP_Error( 'not-valid-sqlite-file', 'Query error: not a valid SQLite database' );
244        }
245
246        return true;
247    }
248
249    /**
250     * Generate the table inserts.
251     *
252     * @param SQL_Generator $generator   The SQL generator.
253     * @param string        $table_name  The table name.
254     * @param string        $format      The format string.
255     * @param string        $field_names The field names.
256     *
257     * @return void|WP_Error
258     */
259    private function generate_inserts( SQL_Generator $generator, string $table_name, string $format, string $field_names ) {
260        $entries = $this->db->query( "SELECT * FROM {$table_name}" );
261
262        if ( ! $entries ) {
263            return new WP_Error( 'missing-table', 'Query error: can\'t read source entry.' );
264        }
265
266        while ( $entry = $entries->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
267            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared outside.
268            $generator->table_insert( $field_names, $this->prepare( $format, $entry ) );
269        }
270    }
271
272    /**
273     * Get the input table name without the prefix.
274     *
275     * @param string $table_name The table name.
276     *
277     * @return string
278     */
279    private function get_input_table_name( string $table_name ): string {
280        return substr( $table_name, 0, strlen( $this->options['prefix'] ) ) === $this->options['prefix'] ?
281            substr( $table_name, strlen( $this->options['prefix'] ) ) :
282            $table_name;
283    }
284
285    /**
286     * Get the output table name.
287     *
288     * @param string $table_name The table name.
289     *
290     * @return string
291     */
292    private function get_output_table_name( string $table_name ): string {
293        // Add the temporary prefix, if needed.
294        $prefix = $this->options['tmp_tables'] ? $this->options['tmp_prefix'] : '';
295
296        // Add the output prefix, if needed.
297        $prefix .= $this->options['output_prefix'] ? $this->options['output_prefix'] : '';
298
299        return $prefix . $table_name;
300    }
301
302    /**
303     * Get the table types map.
304     *
305     * @param string $table_name The table name.
306     *
307     * @return array|WP_Error
308     */
309    private function get_table_types_map( string $table_name ) {
310        if ( ! $this->db ) {
311            return new WP_Error( 'no-database-connection', 'No database connection.' );
312        }
313
314        // Get the "type map" of the table.
315        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQLITE_DATA_TYPES_TABLE is a constant string.
316        $query   = $this->prepare( 'SELECT COLUMN_NAME, COLUMN_TYPE from ' . self::SQLITE_DATA_TYPES_TABLE . ' where `TABLE_NAME`=%s;', $table_name );
317        $results = $this->db->query( $query );
318
319        if ( ! $results ) {
320            return new WP_Error( 'missing-types-cache', 'Query error: missing data types cache' );
321        }
322
323        $mysql_map = array();
324
325        // Schema: COLUMN_NAME|COLUMN_TYPE
326        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
327            // Map by column name and MySQL type.
328            $mysql_map[ $column['COLUMN_NAME'] ] = $column['COLUMN_TYPE'];
329        }
330
331        // Tables like `'_wp_sqlite_*` do not have entries in the `_wp_sqlite_mysql_information_schema_columns` table.
332        // In this case, we return an empty map.
333        if ( empty( $mysql_map ) ) {
334            return array(
335                'map'            => array(),
336                'auto_increment' => 0,
337                'field_names'    => null,
338                'format'         => null,
339            );
340        }
341
342        // Get the "table info" of the table.
343        $query         = $this->prepare( 'PRAGMA TABLE_INFO(%s)', $table_name );
344        $results       = $this->db->query( $query );
345        $primary_count = 0;
346
347        if ( ! $results ) {
348            return new WP_Error( 'missing-table-info', 'Query error: missing table info' );
349        }
350
351        // Our map.
352        $map         = array();
353        $map_by_name = array();
354        $index       = 0;
355        $has_autoinc = true;
356        $formats     = array();
357        $field_names = array();
358
359        // Schema: cid|name|type|notnull|dflt_value|pk
360        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
361            $is_primary    = $column['pk'] >= 1;
362            $field_names[] = $column['name'];
363
364            if ( ! array_key_exists( $column['name'], $mysql_map ) ) {
365                return new WP_Error(
366                    'missing-column',
367                    sprintf( 'Query error: not a valid SQLite table "%s", missing column "%s"', $table_name, $column['name'] )
368                );
369            }
370
371            // Add map info.
372            $map[] = array(
373                'name'           => $column['name'],
374                'type'           => $mysql_map[ $column['name'] ],
375                'sqlite_type'    => $column['type'],
376                'not_null'       => (bool) $column['notnull'],
377                'default'        => $column['dflt_value'],
378                'primary'        => $is_primary,
379                'auto_increment' => $is_primary,
380            );
381
382            $map_by_name[ $column['name'] ] = $index;
383
384            if ( $is_primary ) {
385                ++$primary_count;
386            }
387
388            $formats[] = $this->sqlite_type_to_format( $column['type'] );
389            ++$index;
390        }
391
392        // If the primary key is not a single column, then there is not autoincrement.
393        if ( $primary_count !== 1 ) {
394            $has_autoinc = false;
395
396            foreach ( $map as $index => $column ) {
397                $map[ $index ]['auto_increment'] = false;
398            }
399        }
400
401        // Load table indices.
402        $query   = $this->prepare( 'SELECT name, sql FROM sqlite_master WHERE type=\'index\' AND tbl_name=%s', $table_name );
403        $results = $this->db->query( $query );
404
405        if ( ! $results ) {
406            return new WP_Error( 'missing-table-indices', 'Query error: not a valid SQLite database' );
407        }
408
409        // Loop all indices.
410        // Schema: name|sql
411        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
412            // Some SQLite columns are not indexed. See https://sqlite.org/forum/info/f16f8ed8666c5e97
413            if ( $column['sql'] === null ) {
414                continue;
415            }
416
417            // Strip out the index definition.
418            // wp_comments__comment_approved_date_gmt|CREATE INDEX "wp_comments__comment_approved_date_gmt" ON "wp_comments" ("comment_approved", "comment_date_gmt")
419            $split_query = explode( '` ON `' . $table_name . '` ', $column['sql'] );
420            $real_name   = SQL_Generator::get_index_name( $column['name'] );
421            $new_index   = array(
422                'name'    => $real_name,
423                'type'    => ( strpos( $column['sql'], 'CREATE UNIQUE INDEX' ) === 0 ) ? 'UNIQUE' : 'KEY',
424                'columns' => $split_query[1],
425            );
426
427            if ( array_key_exists( $real_name, $map_by_name ) ) {
428                $index = $map_by_name[ $real_name ];
429
430                if ( $this->needs_191_limit( $map[ $index ] ) ) {
431                    // See wp_get_db_schema $max_index_length for more info about why '191' must be added.
432                    $new_index['columns'] = str_replace( '`)', '`(191))', $new_index['columns'] );
433                }
434            }
435
436            $map[] = $new_index;
437        }
438
439        // Hot fix: add the missing index sizes.
440        $map = $this->hot_fix_missing_indexes( $table_name, $map );
441
442        $auto_increment = 0;
443
444        if ( $has_autoinc ) {
445            $auto_increment = $this->get_table_autoincrement( $table_name );
446        }
447
448        return array(
449            'map'            => $map,
450            'auto_increment' => $auto_increment,
451            'field_names'    => '(`' . implode( '`,`', $field_names ) . '`)',
452            'format'         => '(' . implode( ',', $formats ) . ')',
453        );
454    }
455
456    /**
457     * Get the SQLite type to MySQL format.
458     *
459     * @param string $type The SQLite type.
460     *
461     * @return string
462     */
463    public function sqlite_type_to_format( string $type ): string {
464        switch ( $type ) {
465            case 'integer':
466                return '%d';
467            case 'real':
468                return '%f';
469            case 'text':
470                return '%s';
471            case 'blob':
472                return '%s';
473            case 'null':
474                return '%s';
475            default:
476                return '%s';
477        }
478    }
479
480    /**
481     * Check if the maximum index length of 191 must be added.
482     *
483     * @see wp_get_db_schema $max_index_length for more info.
484     *
485     * @param array $key_map The key map.
486     *
487     * @return bool
488     */
489    public function needs_191_limit( array $key_map ): bool {
490        if ( $key_map['sqlite_type'] !== 'text' ) {
491            return false;
492        }
493
494        // Check if the column type is a text type.
495        $ret = in_array( $key_map['type'], array( 'text', 'tinytext', 'mediumtext', 'longtext' ), true );
496
497        if ( $ret ) {
498            return $ret;
499        }
500
501        // Check if the string is of type varchar(255).
502        $matches = null;
503        preg_match( '/varchar\((\d+)\)/', $key_map['type'], $matches );
504
505        if ( isset( $matches[1] ) ) {
506            // The limit must be added only if the column length is greater than 191.
507            return (int) $matches[1] > 191;
508        }
509
510        return false;
511    }
512
513    /**
514     * Get the table autoincrement value.
515     *
516     * @param string $table_name The table name.
517     *
518     * @return int
519     */
520    private function get_table_autoincrement( $table_name ): int {
521        $query = $this->prepare( 'SELECT seq from ' . self::SQLITE_SEQUENCE_TABLE . ' WHERE name=%s', $table_name );
522
523        return $this->db->querySingle( $query ) ?? 0;
524    }
525
526    /**
527     * Get an export random file name.
528     *
529     * @return string
530     */
531    public function get_tmp_file_name(): string {
532        // A random string to avoid collisions.
533        return 'sqlite-export-' . uniqid() . '.sql';
534    }
535
536    /**
537     * Prepare a query.
538     *
539     * @param string $query The query.
540     * @param mixed  ...$args The arguments.
541     *
542     * @return string|void
543     */
544    private function prepare( $query, ...$args ) {
545        global $wpdb;
546
547        $query = call_user_func_array( array( $wpdb, 'prepare' ), array_merge( array( $query ), $args ) );
548
549        if ( is_string( $query ) ) {
550            return $wpdb->remove_placeholder_escape( $query );
551        }
552    }
553
554    /**
555     * Hot fix: add the missing index sizes. The default SQLite driver do not
556     * generate index sizes and sometimes the indexes are not valid when made of
557     * multiple text and integer columns and are more than 191 characters.
558     *
559     * Known columns are from WooCommerce.
560     *
561     * Example:
562     * KEY `order_id_meta_key_meta_value` (`order_id`, `meta_key`, `meta_value`)
563     *
564     * should be:
565     * KEY `order_id_meta_key_meta_value` (`order_id`,`meta_key`(100),`meta_value`(82))
566     *
567     * Once we have a better SQLite driver, we can remove this function.
568     *
569     * @param string $table_name The table name.
570     * @param array  $map The columns map.
571     *
572     * @return array
573     */
574    public function hot_fix_missing_indexes( string $table_name, array $map ) {
575        // Fix: the default SQLite driver do not generate save index sizes.
576        $fix_map = array(
577            'wp_wc_orders'                           => array(
578                'customer_id_billing_email' => '(`customer_id`,`billing_email`(171))',
579            ),
580            'wp_wc_orders_meta'                      => array(
581                'meta_key_value'               => '(`meta_key`(100),`meta_value`(82))',
582                'order_id_meta_key_meta_value' => '(`order_id`,`meta_key`(100),`meta_value`(82))',
583            ),
584            'wp_woocommerce_downloadable_product_permissions' => array(
585                'download_order_key_product' => '(`product_id`,`order_id`,`order_key`(16),`download_id`)',
586            ),
587            'wp_woocommerce_shipping_zone_locations' => array(
588                'location_type_code' => '(`location_type`(10),`location_code`(20))',
589            ),
590            'wp_woocommerce_tax_rate_locations'      => array(
591                'location_type_code' => '(`location_type`(10),`location_code`(20))',
592            ),
593        );
594
595        if ( ! array_key_exists( $table_name, $fix_map ) ) {
596            // No fix for this table.
597            return $map;
598        }
599
600        foreach ( $map as $i => $column ) {
601            if ( array_key_exists( $column['name'], $fix_map[ $table_name ] ) ) {
602                // Hot fix: add the missing index sizes.
603                $map[ $i ]['columns'] = $fix_map[ $table_name ][ $column['name'] ];
604            }
605        }
606
607        return $map;
608    }
609
610    /**
611     * Check if the table is valid.
612     *
613     * @param string $table_name The table name.
614     *
615     * @return bool
616     */
617    public function is_valid_table( string $table_name ): bool {
618        // Skip SQLite internal tables.
619        if ( $table_name === self::SQLITE_DATA_TYPES_TABLE || $table_name === self::SQLITE_SEQUENCE_TABLE ) {
620            return false;
621        }
622
623        // Skip WordPress internal tables.
624        if ( strpos( $table_name, '_wp_sqlite' ) === 0 ) {
625            return false;
626        }
627
628        return true;
629    }
630}