Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.50% covered (warning)
79.50%
190 / 239
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playground_DB_Importer
79.83% covered (warning)
79.83%
190 / 238
57.14% covered (warning)
57.14%
8 / 14
130.20
0.00% covered (danger)
0.00%
0 / 1
 generate_sql
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
9.53
 parse_database
87.80% covered (warning)
87.80%
36 / 41
0.00% covered (danger)
0.00%
0 / 1
17.52
 check_database_integrity
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 generate_inserts
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 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
67.05% covered (warning)
67.05%
59 / 88
0.00% covered (danger)
0.00%
0 / 1
34.32
 sqlite_type_to_format
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
11.60
 needs_191_limit
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 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 = '_mysql_data_types_cache';
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_or_index, mysql_type from ' . self::SQLITE_DATA_TYPES_TABLE . ' where `table`=%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_or_index|mysql_type
326        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
327            // Hot fix: the default SQLite driver sometimes generate a column named `KEY`.
328            if ( $column['column_or_index'] === 'KEY' ) {
329                continue;
330            }
331
332            // Map by column name and MySQL type.
333            $mysql_map[ $column['column_or_index'] ] = $column['mysql_type'];
334        }
335
336        // Tables like `'_wp_sqlite_*` do not have entries in the `_mysql_data_types_cache` table.
337        // In this case, we return an empty map.
338        if ( empty( $mysql_map ) ) {
339            return array(
340                'map'            => array(),
341                'auto_increment' => 0,
342                'field_names'    => null,
343                'format'         => null,
344            );
345        }
346
347        // Get the "table info" of the table.
348        $query         = $this->prepare( 'PRAGMA TABLE_INFO(%s)', $table_name );
349        $results       = $this->db->query( $query );
350        $primary_count = 0;
351
352        if ( ! $results ) {
353            return new WP_Error( 'missing-table-info', 'Query error: missing table info' );
354        }
355
356        // Our map.
357        $map         = array();
358        $map_by_name = array();
359        $index       = 0;
360        $has_autoinc = true;
361        $formats     = array();
362        $field_names = array();
363
364        // Schema: cid|name|type|notnull|dflt_value|pk
365        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
366            // Hot fix: skip `KEY` columns.
367            if ( $column['name'] === 'KEY' ) {
368                continue;
369            }
370
371            $is_primary    = $column['pk'] >= 1;
372            $field_names[] = $column['name'];
373
374            if ( ! array_key_exists( $column['name'], $mysql_map ) ) {
375                return new WP_Error(
376                    'missing-column',
377                    sprintf( 'Query error: not a valid SQLite table "%s", missing column "%s"', $table_name, $column['name'] )
378                );
379            }
380
381            // Add map info.
382            $map[] = array(
383                'name'           => $column['name'],
384                'type'           => $mysql_map[ $column['name'] ],
385                'sqlite_type'    => $column['type'],
386                'not_null'       => (bool) $column['notnull'],
387                'default'        => $column['dflt_value'],
388                'primary'        => $is_primary,
389                'auto_increment' => $is_primary,
390            );
391
392            $map_by_name[ $column['name'] ] = $index;
393
394            if ( $is_primary ) {
395                ++$primary_count;
396            }
397
398            $formats[] = $this->sqlite_type_to_format( $column['type'] );
399            ++$index;
400        }
401
402        // If the primary key is not a single column, then there is not autoincrement.
403        if ( $primary_count !== 1 ) {
404            $has_autoinc = false;
405
406            foreach ( $map as $index => $column ) {
407                $map[ $index ]['auto_increment'] = false;
408            }
409        }
410
411        // Load table indices.
412        $query   = $this->prepare( 'SELECT name, sql FROM sqlite_master WHERE type=\'index\' AND tbl_name=%s', $table_name );
413        $results = $this->db->query( $query );
414
415        if ( ! $results ) {
416            return new WP_Error( 'missing-table-indices', 'Query error: not a valid SQLite database' );
417        }
418
419        // Loop all indices.
420        // Schema: name|sql
421        while ( $column = $results->fetchArray( SQLITE3_ASSOC ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
422            // Some SQLite columns are not indexed. See https://sqlite.org/forum/info/f16f8ed8666c5e97
423            if ( ! array_key_exists( $column['name'], $mysql_map ) ) {
424                continue;
425            }
426
427            if ( ! in_array( $mysql_map[ $column['name'] ], array( 'KEY', 'UNIQUE' ), true ) ) {
428                return new WP_Error( 'missing-index', 'Query error: not a valid SQLite database, missing index' );
429            }
430
431            // Strip out the index definition.
432            // wp_comments__comment_approved_date_gmt|CREATE INDEX "wp_comments__comment_approved_date_gmt" ON "wp_comments" ("comment_approved", "comment_date_gmt")
433            $split_query = explode( '" ON "' . $table_name . '" ', $column['sql'] );
434            $real_name   = SQL_Generator::get_index_name( $column['name'] );
435            $new_index   = array(
436                'name'    => $real_name,
437                'type'    => $mysql_map[ $column['name'] ],
438                'columns' => str_replace( '"', '`', $split_query[1] ),
439            );
440
441            if ( array_key_exists( $real_name, $map_by_name ) ) {
442                $index = $map_by_name[ $real_name ];
443
444                if ( $this->needs_191_limit( $map[ $index ] ) ) {
445                    // See wp_get_db_schema $max_index_length for more info about why '191' must be added.
446                    $new_index['columns'] = str_replace( '`)', '`(191))', $new_index['columns'] );
447                }
448            }
449
450            $map[] = $new_index;
451        }
452
453        // Hot fix: add the missing index sizes.
454        $map = $this->hot_fix_missing_indexes( $table_name, $map );
455
456        $auto_increment = 0;
457
458        if ( $has_autoinc ) {
459            $auto_increment = $this->get_table_autoincrement( $table_name );
460        }
461
462        return array(
463            'map'            => $map,
464            'auto_increment' => $auto_increment,
465            'field_names'    => '(`' . implode( '`,`', $field_names ) . '`)',
466            'format'         => '(' . implode( ',', $formats ) . ')',
467        );
468    }
469
470    /**
471     * Get the SQLite type to MySQL format.
472     *
473     * @param string $type The SQLite type.
474     *
475     * @return string
476     */
477    private function sqlite_type_to_format( string $type ): string {
478        switch ( $type ) {
479            case 'integer':
480                return '%d';
481            case 'real':
482                return '%f';
483            case 'text':
484                return '%s';
485            case 'blob':
486                return '%s';
487            case 'null':
488                return '%s';
489            default:
490                return '%s';
491        }
492    }
493
494    /**
495     * Check if the maximum index length of 191 must be added.
496     *
497     * @see wp_get_db_schema $max_index_length for more info.
498     *
499     * @param array $key_map The key map.
500     *
501     * @return bool
502     */
503    public function needs_191_limit( array $key_map ): bool {
504        if ( $key_map['sqlite_type'] !== 'text' ) {
505            return false;
506        }
507
508        // Check if the column type is a text type.
509        $ret = in_array( $key_map['type'], array( 'text', 'tinytext', 'mediumtext', 'longtext' ), true );
510
511        if ( $ret ) {
512            return $ret;
513        }
514
515        // Check if the string is of type varchar(255).
516        $matches = null;
517        preg_match( '/varchar\((\d+)\)/', $key_map['type'], $matches );
518
519        if ( isset( $matches[1] ) ) {
520            // The limit must be added only if the column length is greater than 191.
521            return (int) $matches[1] > 191;
522        }
523
524        return false;
525    }
526
527    /**
528     * Get the table autoincrement value.
529     *
530     * @param string $table_name The table name.
531     *
532     * @return int
533     */
534    private function get_table_autoincrement( $table_name ): int {
535        $query = $this->prepare( 'SELECT seq from ' . self::SQLITE_SEQUENCE_TABLE . ' WHERE name=%s', $table_name );
536
537        return $this->db->querySingle( $query ) ?? 0;
538    }
539
540    /**
541     * Get an export random file name.
542     *
543     * @return string
544     */
545    public function get_tmp_file_name(): string {
546        // A random string to avoid collisions.
547        return 'sqlite-export-' . uniqid() . '.sql';
548    }
549
550    /**
551     * Prepare a query.
552     *
553     * @param string $query The query.
554     * @param mixed  ...$args The arguments.
555     *
556     * @return string|void
557     */
558    private function prepare( $query, ...$args ) {
559        global $wpdb;
560
561        $query = call_user_func_array( array( $wpdb, 'prepare' ), array_merge( array( $query ), $args ) );
562
563        if ( is_string( $query ) ) {
564            return $wpdb->remove_placeholder_escape( $query );
565        }
566    }
567
568    /**
569     * Hot fix: add the missing index sizes. The default SQLite driver do not
570     * generate index sizes and sometimes the indexes are not valid when made of
571     * multiple text and integer columns and are more than 191 characters.
572     *
573     * Known columns are from WooCommerce.
574     *
575     * Example:
576     * KEY `order_id_meta_key_meta_value` (`order_id`, `meta_key`, `meta_value`)
577     *
578     * should be:
579     * KEY `order_id_meta_key_meta_value` (`order_id`,`meta_key`(100),`meta_value`(82))
580     *
581     * Once we have a better SQLite driver, we can remove this function.
582     *
583     * @param string $table_name The table name.
584     * @param array  $map The columns map.
585     *
586     * @return array
587     */
588    public function hot_fix_missing_indexes( string $table_name, array $map ) {
589        // Fix: the default SQLite driver do not generate save index sizes.
590        $fix_map = array(
591            'wp_wc_orders'                           => array(
592                'customer_id_billing_email' => '(`customer_id`,`billing_email`(171))',
593            ),
594            'wp_wc_orders_meta'                      => array(
595                'meta_key_value'               => '(`meta_key`(100),`meta_value`(82))',
596                'order_id_meta_key_meta_value' => '(`order_id`,`meta_key`(100),`meta_value`(82))',
597            ),
598            'wp_woocommerce_downloadable_product_permissions' => array(
599                'download_order_key_product' => '(`product_id`,`order_id`,`order_key`(16),`download_id`)',
600            ),
601            'wp_woocommerce_shipping_zone_locations' => array(
602                'location_type_code' => '(`location_type`(10),`location_code`(20))',
603            ),
604            'wp_woocommerce_tax_rate_locations'      => array(
605                'location_type_code' => '(`location_type`(10),`location_code`(20))',
606            ),
607        );
608
609        if ( ! array_key_exists( $table_name, $fix_map ) ) {
610            // No fix for this table.
611            return $map;
612        }
613
614        foreach ( $map as $i => $column ) {
615            if ( array_key_exists( $column['name'], $fix_map[ $table_name ] ) ) {
616                // Hot fix: add the missing index sizes.
617                $map[ $i ]['columns'] = $fix_map[ $table_name ][ $column['name'] ];
618            }
619        }
620
621        return $map;
622    }
623
624    /**
625     * Check if the table is valid.
626     *
627     * @param string $table_name The table name.
628     *
629     * @return bool
630     */
631    public function is_valid_table( string $table_name ): bool {
632        // Skip SQLite internal tables.
633        if ( $table_name === self::SQLITE_DATA_TYPES_TABLE || $table_name === self::SQLITE_SEQUENCE_TABLE ) {
634            return false;
635        }
636
637        // Skip WordPress internal tables.
638        if ( strpos( $table_name, '_wp_sqlite' ) === 0 ) {
639            return false;
640        }
641
642        return true;
643    }
644}