Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 451
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Table_Checksum
0.00% covered (danger)
0.00%
0 / 451
0.00% covered (danger)
0.00%
0 / 19
8742
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 get_default_tables
0.00% covered (danger)
0.00%
0 / 199
0.00% covered (danger)
0.00%
0 / 1
6
 get_allowed_tables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepare_fields
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 validate_table_name
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 validate_fields
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 validate_fields_against_table
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 validate_input
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 prepare_filter_values_as_sql
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 build_filter_statement
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 build_checksum_query
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
156
 get_range_edges
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
380
 reset_range_edges_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_parent_table_count
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 prepare_results_for_output
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 calculate_checksum
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 enable_woocommerce_tables
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 enable_woocommerce_hpos_tables
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 prepare_additional_columns
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Table Checksums Class.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Replicastore;
9
10use Automattic\Jetpack\Sync;
11use Automattic\Jetpack\Sync\Modules\WooCommerce_HPOS_Orders;
12use Exception;
13use WP_Error;
14
15// TODO add rest endpoints to work with this, hopefully in the same folder.
16/**
17 * Class to handle Table Checksums.
18 */
19class Table_Checksum {
20
21    /**
22     * Table to be checksummed.
23     *
24     * @var string
25     */
26    public $table = '';
27
28    /**
29     * Table Checksum Configuration.
30     *
31     * @var array
32     */
33    public $table_configuration = array();
34
35    /**
36     * Perform Text Conversion to latin1.
37     *
38     * @var boolean
39     */
40    protected $perform_text_conversion = false;
41
42    /**
43     * Field to be used for range queries.
44     *
45     * @var string
46     */
47    public $range_field = '';
48
49    /**
50     * ID Field(s) to be used.
51     *
52     * @var array
53     */
54    public $key_fields = array();
55
56    /**
57     * Field(s) to be used in generating the checksum value.
58     *
59     * @var array
60     */
61    public $checksum_fields = array();
62
63    /**
64     * Field(s) to be used in generating the checksum value that need latin1 conversion.
65     *
66     * @var array
67     */
68    public $checksum_text_fields = array();
69
70    /**
71     * Default filter values for the table
72     *
73     * @var array
74     */
75    public $filter_values = array();
76
77    /**
78     * SQL Query to be used to filter results (allow/disallow).
79     *
80     * @var string
81     */
82    public $additional_filter_sql = '';
83
84    /**
85     * Default Checksum Table Configurations.
86     *
87     * @var array
88     */
89    public $default_tables = array();
90
91    /**
92     * Salt to be used when generating checksum.
93     *
94     * @var string
95     */
96    public $salt = '';
97
98    /**
99     * Tables which are allowed to be checksummed.
100     *
101     * @var string
102     */
103    public $allowed_tables = array();
104
105    /**
106     * If the table has a "parent" table that it's related to.
107     *
108     * @var mixed|null
109     */
110    protected $parent_table = null;
111
112    /**
113     * What field to use for the parent table join, if it has a "parent" table.
114     *
115     * @var mixed|null
116     */
117    protected $parent_join_field = null;
118
119    /**
120     * What field to use for the table join, if it has a "parent" table.
121     *
122     * @var mixed|null
123     */
124    protected $table_join_field = null;
125
126    /**
127     * Some tables might not exist on the remote, and we want to verify they exist, before trying to query them.
128     *
129     * @var callable
130     */
131    protected $is_table_enabled_callback = false;
132
133    /**
134     * Table_Checksum constructor.
135     *
136     * @param string  $table                   The table to calculate checksums for.
137     * @param string  $salt                    Optional salt to add to the checksum.
138     * @param boolean $perform_text_conversion If text fields should be latin1 converted.
139     * @param array   $additional_columns      Additional columns to add to the checksum calculation.
140     *
141     * @throws Exception Throws exception from inner functions.
142     */
143    public function __construct( $table, $salt = null, $perform_text_conversion = false, $additional_columns = null ) {
144
145        if ( ! Sync\Settings::is_checksum_enabled() ) {
146            throw new Exception( 'Checksums are currently disabled.' );
147        }
148
149        $this->salt = $salt;
150
151        $this->default_tables = static::get_default_tables();
152
153        $this->perform_text_conversion = $perform_text_conversion;
154
155        // TODO change filters to allow the array format.
156        // TODO add get_fields or similar method to get things out of the table.
157        // TODO extract this configuration in a better way, still make it work with `$wpdb` names.
158        // TODO take over the replicastore functions and move them over to this class.
159        // TODO make the API work.
160
161        $this->allowed_tables = apply_filters( 'jetpack_sync_checksum_allowed_tables', $this->default_tables );
162
163        $this->table               = $this->validate_table_name( $table );
164        $this->table_configuration = $this->allowed_tables[ $table ];
165
166        $this->prepare_fields( $this->table_configuration );
167
168        $this->prepare_additional_columns( $additional_columns );
169
170        // Run any callbacks to check if a table is enabled or not.
171        if (
172            is_callable( $this->is_table_enabled_callback )
173            && ! call_user_func( $this->is_table_enabled_callback, $table )
174        ) {
175            throw new Exception( "Unable to use table name: $table" );
176        }
177    }
178
179    /**
180     * Get Default Table configurations.
181     *
182     * @return array
183     */
184    protected static function get_default_tables() {
185        global $wpdb;
186
187        return array(
188            'posts'                      => array(
189                'table'                     => $wpdb->posts,
190                'range_field'               => 'ID',
191                'key_fields'                => array( 'ID' ),
192                'checksum_fields'           => array( 'post_modified_gmt' ),
193                'filter_values'             => Sync\Settings::get_disallowed_post_types_structured(),
194                'is_table_enabled_callback' => function () {
195                    return false !== Sync\Modules::get_module( 'posts' );
196                },
197            ),
198            'postmeta'                   => array(
199                'table'                     => $wpdb->postmeta,
200                'range_field'               => 'post_id',
201                'key_fields'                => array( 'post_id', 'meta_key' ),
202                'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
203                'filter_values'             => Sync\Settings::get_allowed_post_meta_structured(),
204                'parent_table'              => 'posts',
205                'parent_join_field'         => 'ID',
206                'table_join_field'          => 'post_id',
207                'is_table_enabled_callback' => function () {
208                    return false !== Sync\Modules::get_module( 'posts' );
209                },
210            ),
211            'comments'                   => array(
212                'table'                     => $wpdb->comments,
213                'range_field'               => 'comment_ID',
214                'key_fields'                => array( 'comment_ID' ),
215                'checksum_fields'           => array( 'comment_date_gmt' ),
216                'filter_values'             => array_merge(
217                    Sync\Settings::get_allowed_comment_types_structured(),
218                    array(
219                        'comment_approved' => array(
220                            'operator' => 'NOT IN',
221                            'values'   => array( 'spam' ),
222                        ),
223                    )
224                ),
225                'is_table_enabled_callback' => function () {
226                    return false !== Sync\Modules::get_module( 'comments' );
227                },
228            ),
229            'commentmeta'                => array(
230                'table'                     => $wpdb->commentmeta,
231                'range_field'               => 'comment_id',
232                'key_fields'                => array( 'comment_id', 'meta_key' ),
233                'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
234                'filter_values'             => Sync\Settings::get_allowed_comment_meta_structured(),
235                'parent_table'              => 'comments',
236                'parent_join_field'         => 'comment_ID',
237                'table_join_field'          => 'comment_id',
238                'is_table_enabled_callback' => function () {
239                    return false !== Sync\Modules::get_module( 'comments' );
240                },
241            ),
242            'terms'                      => array(
243                'table'                     => $wpdb->terms,
244                'range_field'               => 'term_id',
245                'key_fields'                => array( 'term_id' ),
246                'checksum_fields'           => array( 'term_id' ),
247                'checksum_text_fields'      => array( 'name', 'slug' ),
248                'parent_table'              => 'term_taxonomy',
249                'is_table_enabled_callback' => function () {
250                    return false !== Sync\Modules::get_module( 'terms' );
251                },
252            ),
253            'termmeta'                   => array(
254                'table'                     => $wpdb->termmeta,
255                'range_field'               => 'term_id',
256                'key_fields'                => array( 'term_id', 'meta_key' ),
257                'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
258                'parent_table'              => 'term_taxonomy',
259                'is_table_enabled_callback' => function () {
260                    return false !== Sync\Modules::get_module( 'terms' );
261                },
262            ),
263            'term_relationships'         => array(
264                'table'                     => $wpdb->term_relationships,
265                'range_field'               => 'object_id',
266                'key_fields'                => array( 'object_id' ),
267                'checksum_fields'           => array( 'object_id', 'term_taxonomy_id' ),
268                'parent_table'              => 'term_taxonomy',
269                'parent_join_field'         => 'term_taxonomy_id',
270                'table_join_field'          => 'term_taxonomy_id',
271                'is_table_enabled_callback' => function () {
272                    return false !== Sync\Modules::get_module( 'terms' );
273                },
274            ),
275            'term_taxonomy'              => array(
276                'table'                     => $wpdb->term_taxonomy,
277                'range_field'               => 'term_taxonomy_id',
278                'key_fields'                => array( 'term_taxonomy_id' ),
279                'checksum_fields'           => array( 'term_taxonomy_id', 'term_id', 'parent' ),
280                'checksum_text_fields'      => array( 'taxonomy', 'description' ),
281                'filter_values'             => Sync\Settings::get_allowed_taxonomies_structured(),
282                'is_table_enabled_callback' => function () {
283                    return false !== Sync\Modules::get_module( 'terms' );
284                },
285            ),
286            'links'                      => $wpdb->links, // TODO describe in the array format or add exceptions.
287            'options'                    => $wpdb->options, // TODO describe in the array format or add exceptions.
288            'wc_product_lookup'          => array( // wc_product_lookup is a table in the cache database
289                'table'                     => $wpdb->posts,
290                'range_field'               => 'ID',
291                'key_fields'                => array( 'ID' ),
292                'checksum_fields'           => array( 'post_modified_gmt' ),
293                'filter_values'             => array(
294                    'post_type' => array(
295                        'operator' => 'IN',
296                        'values'   => array( 'product', 'product_variation' ),
297                    ),
298                ),
299                'is_table_enabled_callback' => function () {
300                    return false !== Sync\Modules::get_module( 'woocommerce_products' );
301                },
302            ),
303            'woocommerce_order_items'    => array(
304                'table'                     => "{$wpdb->prefix}woocommerce_order_items",
305                'range_field'               => 'order_item_id',
306                'key_fields'                => array( 'order_item_id' ),
307                'checksum_fields'           => array( 'order_id' ),
308                'checksum_text_fields'      => array( 'order_item_name', 'order_item_type' ),
309                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_tables',
310            ),
311            'woocommerce_order_itemmeta' => array(
312                'table'                     => "{$wpdb->prefix}woocommerce_order_itemmeta",
313                'range_field'               => 'order_item_id',
314                'key_fields'                => array( 'order_item_id', 'meta_key' ),
315                'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
316                'filter_values'             => Sync\Settings::get_allowed_order_itemmeta_structured(),
317                'parent_table'              => 'woocommerce_order_items',
318                'parent_join_field'         => 'order_item_id',
319                'table_join_field'          => 'order_item_id',
320                'is_table_enabled_callback' => function () {
321                    return false !== Sync\Modules::get_module( 'meta' ) && self::enable_woocommerce_tables();
322                },
323            ),
324            'wc_orders'                  => array(
325                'table'                     => "{$wpdb->prefix}wc_orders",
326                'range_field'               => 'id',
327                'key_fields'                => array( 'id' ),
328                'checksum_fields'           => array( 'date_updated_gmt', 'total_amount' ),
329                'checksum_text_fields'      => array( 'type', 'status' ),
330                'filter_values'             => array(
331                    'type'   => array(
332                        'operator' => 'IN',
333                        'values'   => WooCommerce_HPOS_Orders::get_order_types_to_sync( true ),
334                    ),
335                    'status' => array(
336                        'operator' => 'IN',
337                        'values'   => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
338                    ),
339                ),
340                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
341            ),
342            'wc_order_addresses'         => array(
343                'table'                     => "{$wpdb->prefix}wc_order_addresses",
344                'range_field'               => 'order_id',
345                'key_fields'                => array( 'order_id', 'address_type' ),
346                'checksum_text_fields'      => array( 'address_type' ),
347                'parent_table'              => 'wc_orders',
348                'parent_join_field'         => 'id',
349                'table_join_field'          => 'order_id',
350                'filter_values'             => array(),
351                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
352            ),
353            'wc_order_operational_data'  => array(
354                'table'                     => "{$wpdb->prefix}wc_order_operational_data",
355                'range_field'               => 'order_id',
356                'key_fields'                => array( 'order_id' ),
357                'checksum_fields'           => array( 'date_paid_gmt', 'date_completed_gmt' ),
358                'checksum_text_fields'      => array( 'order_key' ),
359                'parent_table'              => 'wc_orders',
360                'parent_join_field'         => 'id',
361                'table_join_field'          => 'order_id',
362                'filter_values'             => array(),
363                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
364            ),
365            'users'                      => array(
366                'table'                     => $wpdb->users,
367                'range_field'               => 'ID',
368                'key_fields'                => array( 'ID' ),
369                'checksum_text_fields'      => array( 'user_login', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_status', 'display_name' ),
370                'filter_values'             => array(),
371                'is_table_enabled_callback' => function () {
372                    return false !== Sync\Modules::get_module( 'users' );
373                },
374            ),
375
376            /**
377             * Usermeta is a special table, as it needs to use a custom override flow,
378             * as the user roles, capabilities, locale, mime types can be filtered by plugins.
379             * This prevents us from doing a direct comparison in the database.
380             */
381            'usermeta'                   => array(
382                'table'                     => $wpdb->users,
383                /**
384                 * Range field points to ID, which in this case is the `WP_User` ID,
385                 * since we're querying the whole WP_User objects, instead of meta entries in the DB.
386                 */
387                'range_field'               => 'ID',
388                'key_fields'                => array(),
389                'checksum_fields'           => array(),
390                'is_table_enabled_callback' => function () {
391                    return false !== Sync\Modules::get_module( 'users' );
392                },
393            ),
394        );
395    }
396
397    /**
398     * Get allowed table configurations.
399     *
400     * @return array
401     */
402    public static function get_allowed_tables() {
403        return apply_filters( 'jetpack_sync_checksum_allowed_tables', static::get_default_tables() );
404    }
405
406    /**
407     * Prepare field params based off provided configuration.
408     *
409     * @param array $table_configuration The table configuration array.
410     */
411    protected function prepare_fields( $table_configuration ) {
412        $this->key_fields                = $table_configuration['key_fields'];
413        $this->range_field               = $table_configuration['range_field'];
414        $this->checksum_fields           = $table_configuration['checksum_fields'] ?? array();
415        $this->checksum_text_fields      = $table_configuration['checksum_text_fields'] ?? array();
416        $this->filter_values             = $table_configuration['filter_values'] ?? null;
417        $this->additional_filter_sql     = ! empty( $table_configuration['filter_sql'] ) ? $table_configuration['filter_sql'] : '';
418        $this->parent_table              = $table_configuration['parent_table'] ?? null;
419        $this->parent_join_field         = $table_configuration['parent_join_field'] ?? $table_configuration['range_field'];
420        $this->table_join_field          = $table_configuration['table_join_field'] ?? $table_configuration['range_field'];
421        $this->is_table_enabled_callback = $table_configuration['is_table_enabled_callback'] ?? false;
422    }
423
424    /**
425     * Verify provided table name is valid for checksum processing.
426     *
427     * @param string $table Table name to validate.
428     *
429     * @return mixed|string
430     * @throws Exception Throw an exception on validation failure.
431     */
432    protected function validate_table_name( $table ) {
433        if ( empty( $table ) ) {
434            throw new Exception( 'Invalid table name: empty' );
435        }
436
437        if ( ! array_key_exists( $table, $this->allowed_tables ) ) {
438            throw new Exception( "Invalid table name: $table not allowed" );
439        }
440
441        return $this->allowed_tables[ $table ]['table'];
442    }
443
444    /**
445     * Verify provided fields are proper names.
446     *
447     * @param array $fields Array of field names to validate.
448     *
449     * @throws Exception Throw an exception on failure to validate.
450     */
451    protected function validate_fields( $fields ) {
452        foreach ( $fields as $field ) {
453            if ( ! preg_match( '/^[0-9,a-z,A-Z$_]+$/i', $field ) ) {
454                throw new Exception( "Invalid field name: $field is not allowed" );
455            }
456
457            // TODO other verifications of the field names.
458        }
459    }
460
461    /**
462     * Verify the fields exist in the table.
463     *
464     * @param array $fields Array of fields to validate.
465     *
466     * @return bool
467     * @throws Exception Throw an exception on failure to validate.
468     */
469    protected function validate_fields_against_table( $fields ) {
470        global $wpdb;
471
472        $valid_fields = array();
473
474        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
475        $result = $wpdb->get_results( "SHOW COLUMNS FROM {$this->table}", ARRAY_A );
476
477        foreach ( $result as $result_row ) {
478            $valid_fields[] = $result_row['Field'];
479        }
480
481        // Check if the fields are actually contained in the table.
482        foreach ( $fields as $field_to_check ) {
483            if ( ! in_array( $field_to_check, $valid_fields, true ) ) {
484                throw new Exception( "Invalid field name: field '{$field_to_check}' doesn't exist in table {$this->table}" );
485            }
486        }
487
488        return true;
489    }
490
491    /**
492     * Verify the configured fields.
493     *
494     * @throws Exception Throw an exception on failure to validate in the internal functions.
495     */
496    protected function validate_input() {
497        $fields = array_merge( array( $this->range_field ), $this->key_fields, $this->checksum_fields, $this->checksum_text_fields );
498
499        $this->validate_fields( $fields );
500        $this->validate_fields_against_table( $fields );
501    }
502
503    /**
504     * Prepare filter values as SQL statements to be added to the other filters.
505     *
506     * @param array  $filter_values The filter values array.
507     * @param string $table_prefix  If the values are going to be used in a sub-query, add a prefix with the table alias.
508     *
509     * @return array|null
510     */
511    protected function prepare_filter_values_as_sql( $filter_values = array(), $table_prefix = '' ) {
512        global $wpdb;
513
514        if ( ! is_array( $filter_values ) ) {
515            return null;
516        }
517
518        $result = array();
519
520        foreach ( $filter_values as $field => $filter ) {
521            $key = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.' . $field;
522
523            switch ( $filter['operator'] ) {
524                case 'IN':
525                case 'NOT IN':
526                    $filter_values_count = is_countable( $filter['values'] ) ? count( $filter['values'] ) : 0;
527                    $values_placeholders = implode( ',', array_fill( 0, $filter_values_count, '%s' ) );
528                    $statement           = "{$key} {$filter['operator']} ( $values_placeholders )";
529
530                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
531                    $prepared_statement = $wpdb->prepare( $statement, $filter['values'] );
532
533                    $result[] = $prepared_statement;
534                    break;
535            }
536        }
537
538        return $result;
539    }
540
541    /**
542     * Build the filter query baased off range fields and values and the additional sql.
543     *
544     * @param int|null   $range_from    Start of the range.
545     * @param int|null   $range_to      End of the range.
546     * @param array|null $filter_values Additional filter values. Not used at the moment.
547     * @param string     $table_prefix  Table name to be prefixed to the columns. Used in sub-queries where columns can clash.
548     *
549     * @return string
550     */
551    public function build_filter_statement( $range_from = null, $range_to = null, $filter_values = null, $table_prefix = '' ) {
552        global $wpdb;
553
554        // If there is a field prefix that we want to use with table aliases.
555        $parent_prefix = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.';
556
557        /**
558         * Prepare the ranges.
559         */
560
561        $filter_array = array( '1 = 1' );
562        if ( null !== $range_from ) {
563            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
564            $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} >= %d", array( intval( $range_from ) ) );
565        }
566        if ( null !== $range_to ) {
567            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
568            $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} <= %d", array( intval( $range_to ) ) );
569        }
570
571        /**
572         * End prepare the ranges.
573         */
574
575        /**
576         * Prepare data filters.
577         */
578
579        // Default filters.
580        if ( $this->filter_values ) {
581            $prepared_values_statements = $this->prepare_filter_values_as_sql( $this->filter_values, $table_prefix );
582            if ( $prepared_values_statements ) {
583                $filter_array = array_merge( $filter_array, $prepared_values_statements );
584            }
585        }
586
587        // Additional filters.
588        if ( ! empty( $filter_values ) ) {
589            // Prepare filtering.
590            $prepared_values_statements = $this->prepare_filter_values_as_sql( $filter_values, $table_prefix );
591            if ( $prepared_values_statements ) {
592                $filter_array = array_merge( $filter_array, $prepared_values_statements );
593            }
594        }
595
596        // Add any additional filters via direct SQL statement.
597        // Currently used only because we haven't converted all filtering to happen via `filter_values`.
598        // This SQL is NOT prefixed and column clashes can occur when used in sub-queries.
599        if ( $this->additional_filter_sql ) {
600            $filter_array[] = $this->additional_filter_sql;
601        }
602
603        /**
604         * End prepare data filters.
605         */
606        return implode( ' AND ', $filter_array );
607    }
608
609    /**
610     * Returns the checksum query. All validation of fields and configurations are expected to occur prior to usage.
611     *
612     * @param int|null   $range_from      The start of the range.
613     * @param int|null   $range_to        The end of the range.
614     * @param array|null $filter_values   Additional filter values. Not used at the moment.
615     * @param bool       $granular_result If the function should return a granular result.
616     *
617     * @return string
618     *
619     * @throws Exception Throws an exception if validation fails in the internal function calls.
620     */
621    protected function build_checksum_query( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false ) {
622        global $wpdb;
623
624        // Escape the salt.
625        $salt = $wpdb->prepare( '%s', $this->salt );
626
627        // Prepare the compound key.
628        $key_fields = array();
629
630        // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
631        foreach ( $this->key_fields as $field ) {
632            $key_fields[] = $this->table . '.' . $field;
633        }
634
635        $key_fields = implode( ',', $key_fields );
636
637        // Prepare the checksum fields.
638        $checksum_fields = array();
639        // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
640        foreach ( $this->checksum_fields as $field ) {
641            $checksum_fields[] = $this->table . '.' . $field;
642        }
643        // Apply latin1 conversion if enabled.
644        if ( $this->perform_text_conversion ) {
645            // Convert text fields to allow for encoding discrepancies as WP.com is latin1.
646            foreach ( $this->checksum_text_fields as $field ) {
647                $checksum_fields[] = 'CONVERT(' . $this->table . '.' . $field . ' using latin1 )';
648            }
649        } else {
650            // Conversion disabled, default to table prefixing.
651            foreach ( $this->checksum_text_fields as $field ) {
652                $checksum_fields[] = $this->table . '.' . $field;
653            }
654        }
655
656        $checksum_fields_string = implode( ',', array_merge( $checksum_fields, array( $salt ) ) );
657
658        $additional_fields = '';
659        if ( $granular_result ) {
660            // TODO uniq the fields as sometimes(most) range_index is the key and there's no need to select the same field twice.
661            $additional_fields = "
662                {$this->table}.{$this->range_field} as range_index,
663                {$key_fields},
664            ";
665        }
666
667        $filter_stamenet = $this->build_filter_statement( $range_from, $range_to, $filter_values );
668
669        $join_statement = '';
670        // On WPCOM the checksum comparison does not use the parent table INNER JOIN.
671        // WPCOM sets parent_table in its config solely for the count optimization in
672        // get_range_edges(), so we skip the JOIN to avoid query differences.
673        if ( $this->parent_table && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
674            $parent_table_obj    = new Table_Checksum( $this->parent_table );
675            $parent_filter_query = $parent_table_obj->build_filter_statement( null, null, null, 'parent_table' );
676
677            // It is possible to have the GROUP By cause multiple rows to be returned for the same row for term_taxonomy.
678            // To get distinct entries we use a correlatd subquery back on the parent table using the primary key.
679            $additional_unique_clause = '';
680            if ( 'term_taxonomy' === $this->parent_table ) {
681                $additional_unique_clause = "
682                AND parent_table.{$parent_table_obj->range_field} = (
683                SELECT min( parent_table_cs.{$parent_table_obj->range_field} )
684                        FROM {$parent_table_obj->table} as parent_table_cs
685                        WHERE parent_table_cs.{$this->parent_join_field} = {$this->table}.{$this->table_join_field}
686                    )
687                ";
688            }
689
690            $join_statement = "
691                INNER JOIN {$parent_table_obj->table} as parent_table
692                ON (
693                    {$this->table}.{$this->table_join_field} = parent_table.{$this->parent_join_field}
694                    AND {$parent_filter_query}
695                    $additional_unique_clause
696                )
697            ";
698        }
699
700        $query = "
701            SELECT
702                {$additional_fields}
703                SUM(
704                    CRC32(
705                        CONCAT_WS( '#', {$salt}{$checksum_fields_string} )
706                    )
707                )  AS checksum
708             FROM
709                {$this->table}
710                {$join_statement}
711             WHERE
712                {$filter_stamenet}
713        ";
714
715        /**
716         * We need the GROUP BY only for compound keys.
717         */
718        if ( $granular_result ) {
719            $query .= "
720                GROUP BY {$key_fields}
721                LIMIT 9999999
722            ";
723        }
724
725        return $query;
726    }
727
728    /**
729     * Obtain the min-max values (edges) of the range.
730     *
731     * @param int|null $range_from The start of the range.
732     * @param int|null $range_to   The end of the range.
733     * @param int|null $limit      How many values to return.
734     *
735     * @return array|object|void
736     * @throws Exception Throws an exception if validation fails on the internal function calls.
737     */
738    public function get_range_edges( $range_from = null, $range_to = null, $limit = null ) {
739        global $wpdb;
740
741        $this->validate_fields( array( $this->range_field ) );
742
743        // Performance :: For meta tables (postmeta, commentmeta, termmeta, woocommerce_order_itemmeta)
744        // we strip the filter_values (e.g. meta_key whitelist) when building the range edges query.
745        // These filters cause non-performant queries that can timeout on large tables.
746        // The actual data filtering happens during checksum calculation â€” via the filter_values
747        // WHERE clause and, when enabled, the parent table INNER JOIN.
748        $is_meta_table = in_array(
749            $this->table,
750            array( $wpdb->postmeta, $wpdb->commentmeta, $wpdb->termmeta, "{$wpdb->prefix}woocommerce_order_itemmeta" ),
751            true
752        );
753        $filter_values = $this->filter_values;
754        if ( $is_meta_table ) {
755            $this->filter_values = null;
756        }
757
758        // `trim()` to make sure we don't add the statement if it's empty.
759        $filters = trim( $this->build_filter_statement( $range_from, $range_to ) );
760
761        // Restore filter values.
762        if ( $is_meta_table ) {
763            $this->filter_values = $filter_values;
764        }
765
766        $filter_statement = '';
767        if ( ! empty( $filters ) ) {
768            $filter_statement = "
769                WHERE
770                    {$filters}
771            ";
772        }
773
774        // Only make the distinct count when we know there can be multiple entries for the range column.
775        $distinct_count = '';
776        if ( count( $this->key_fields ) > 1 || $wpdb->terms === $this->table || $wpdb->term_relationships === $this->table ) {
777            $distinct_count = 'DISTINCT';
778        }
779
780        $query = "
781            SELECT
782                   MIN({$this->range_field}) as min_range,
783                   MAX({$this->range_field}) as max_range,
784                   COUNT( {$distinct_count} {$this->range_field}) as item_count
785            FROM
786        ";
787
788        /**
789         * If `$limit` is not specified, we can directly use the table.
790         */
791        if ( ! $limit ) {
792            // For tables that would use COUNT(DISTINCT), avoid the expensive full table scan
793            // by using the parent table's count instead. Only for full-table calls â€” sub-range
794            // calls need the actual COUNT(DISTINCT) scoped to the range, and those are cheap
795            // because the WHERE clause limits the scan.
796            if ( $distinct_count && null === $range_from && null === $range_to ) {
797                $parent_count = $this->get_parent_table_count();
798                if ( (int) $parent_count > 0 ) {
799                    $min_max_query = "
800                        SELECT
801                            MIN({$this->range_field}) as min_range,
802                            MAX({$this->range_field}) as max_range
803                        FROM
804                            {$this->table}
805                            {$filter_statement}
806                    ";
807
808                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
809                    $result = $wpdb->get_row( $min_max_query, ARRAY_A );
810
811                    if ( $result && is_array( $result ) ) {
812                        $result['item_count']                    = $parent_count;
813                        self::$range_edges_cache[ $this->table ] = $result;
814                        return $result;
815                    }
816                }
817            }
818
819            $query .= "
820                {$this->table}
821                {$filter_statement}
822            ";
823        } else {
824            /**
825             * If there is `$limit` specified, we can't directly use `MIN/MAX()` as they don't work with `LIMIT`.
826             * That's why we will alter the query for this case.
827             */
828            $limit = intval( $limit );
829
830            $query .= "
831                (
832                    SELECT
833                        {$distinct_count} {$this->range_field}
834                    FROM
835                        {$this->table}
836                        {$filter_statement}
837                    ORDER BY
838                        {$this->range_field} ASC
839                    LIMIT {$limit}
840                ) as ids_query
841            ";
842        }
843
844        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
845        $result = $wpdb->get_row( $query, ARRAY_A );
846
847        if ( ! $result || ! is_array( $result ) ) {
848            throw new Exception( 'Unable to get range edges' );
849        }
850
851        // Cache full-range results so child meta tables can reuse the parent's count.
852        // Only cache when no range constraints â€” sub-range counts would pollute the cache.
853        if ( ! $limit && null === $range_from && null === $range_to ) {
854            self::$range_edges_cache[ $this->table ] = $result;
855        }
856
857        return $result;
858    }
859
860    /**
861     * Static cache for range edge results, keyed by table name.
862     *
863     * When checksum_all() processes tables sequentially, the parent table's
864     * get_range_edges() result is cached so child tables can reuse the
865     * item_count without re-querying.
866     *
867     * @var array
868     */
869    private static $range_edges_cache = array();
870
871    /**
872     * Reset the static range edges cache.
873     *
874     * Should be called when the underlying data changes and cached
875     * counts may be stale (e.g. between test runs).
876     */
877    public static function reset_range_edges_cache() {
878        self::$range_edges_cache = array();
879    }
880
881    /**
882     * Get the row count from the parent table as an approximate item count.
883     *
884     * For tables with compound keys or non-unique range fields, COUNT(DISTINCT range_field)
885     * causes expensive full table scans. Since item_count is only used for bucket sizing
886     * in checksum_histogram(), the parent table's row count is an acceptable approximation.
887     * In typical cases the parent count >= the distinct child count, producing slightly
888     * more (smaller) buckets. The caller guards against a zero parent count (e.g. orphaned
889     * child rows) by falling back to the original COUNT(DISTINCT) query.
890     *
891     * Returns false when the parent table's count is not a reliable proxy (e.g.
892     * term_taxonomy, whose count does not correlate with distinct range_field values
893     * in terms, termmeta, or term_relationships).
894     *
895     * Uses a static cache so that if the parent table was already processed
896     * (e.g. posts before postmeta in checksum_all), no additional query is needed.
897     *
898     * @return int|false The parent table row count, or false if not applicable.
899     */
900    private function get_parent_table_count() {
901        if ( ! $this->parent_table ) {
902            return false;
903        }
904
905        // term_taxonomy's count is not a reliable proxy for the distinct range_field
906        // values in terms, termmeta, or term_relationships.
907        if ( 'term_taxonomy' === $this->parent_table ) {
908            return false;
909        }
910
911        try {
912            $parent_table_obj = new Table_Checksum( $this->parent_table );
913        } catch ( Exception $e ) {
914            return false;
915        }
916
917        // Check static cache first â€” the parent may have been queried already
918        // (e.g. posts processed before postmeta in checksum_all).
919        if ( isset( self::$range_edges_cache[ $parent_table_obj->table ] ) ) {
920            return (int) self::$range_edges_cache[ $parent_table_obj->table ]['item_count'];
921        }
922
923        // Query the parent table's range edges. For single-key parent tables this is
924        // a simple COUNT (no DISTINCT), so it's fast.
925        try {
926            $parent_range = $parent_table_obj->get_range_edges();
927
928            if ( is_array( $parent_range ) && isset( $parent_range['item_count'] ) ) {
929                return (int) $parent_range['item_count'];
930            }
931
932            return false;
933        } catch ( Exception $e ) {
934            return false;
935        }
936    }
937
938    /**
939     * Update the results to have key/checksum format.
940     *
941     * @param array $results Prepare the results for output of granular results.
942     */
943    protected function prepare_results_for_output( &$results ) {
944        // get the compound key.
945        // only return range and compound key for granular results.
946
947        $return_value = array();
948
949        foreach ( $results as &$result ) {
950            // Working on reference to save memory here.
951
952            $key = array();
953            foreach ( $this->key_fields as $field ) {
954                $key[] = $result[ $field ];
955            }
956
957            $return_value[ implode( '-', $key ) ] = $result['checksum'];
958        }
959
960        return $return_value;
961    }
962
963    /**
964     * Calculate the checksum based on provided range and filters.
965     *
966     * @param int|null   $range_from          The start of the range.
967     * @param int|null   $range_to            The end of the range.
968     * @param array|null $filter_values       Additional filter values. Not used at the moment.
969     * @param bool       $granular_result     If the returned result should be granular or only the checksum.
970     * @param bool       $simple_return_value If we want to use a simple return value for non-granular results (return only the checksum, without wrappers).
971     *
972     * @return array|mixed|object|WP_Error|null
973     */
974    public function calculate_checksum( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false, $simple_return_value = true ) {
975
976        if ( ! Sync\Settings::is_checksum_enabled() ) {
977            return new WP_Error( 'checksum_disabled', 'Checksums are currently disabled.' );
978        }
979
980        try {
981            $this->validate_input();
982        } catch ( Exception $ex ) {
983            return new WP_Error( 'invalid_input', $ex->getMessage() );
984        }
985
986        $query = $this->build_checksum_query( $range_from, $range_to, $filter_values, $granular_result );
987
988        global $wpdb;
989
990        if ( ! $granular_result ) {
991            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
992            $result = $wpdb->get_row( $query, ARRAY_A );
993
994            if ( ! is_array( $result ) ) {
995                return new WP_Error( 'invalid_query', "Result wasn't an array" );
996            }
997
998            if ( $simple_return_value ) {
999                return $result['checksum'];
1000            }
1001
1002            return array(
1003                'range'    => $range_from . '-' . $range_to,
1004                'checksum' => $result['checksum'],
1005            );
1006        } else {
1007            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1008            $result = $wpdb->get_results( $query, ARRAY_A );
1009            return $this->prepare_results_for_output( $result );
1010        }
1011    }
1012
1013    /**
1014     * Make sure the WooCommerce tables should be enabled for Checksum/Fix.
1015     *
1016     * @return bool
1017     */
1018    public static function enable_woocommerce_tables() {
1019        /**
1020         * On WordPress.com, we can't directly check if the site has support for WooCommerce.
1021         * Having the option to override the functionality here helps with syncing WooCommerce tables.
1022         *
1023         * @since 10.1
1024         *
1025         * @param bool If we should we force-enable WooCommerce tables support.
1026         */
1027        $force_woocommerce_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce', false );
1028
1029        // If we're forcing WooCommerce tables support, there's no need to check further.
1030        // This is used on WordPress.com.
1031        if ( $force_woocommerce_support ) {
1032            return true;
1033        }
1034
1035        // If the 'woocommerce' module is enabled, this means that WooCommerce class exists.
1036        return false !== Sync\Modules::get_module( 'woocommerce' );
1037    }
1038
1039    /**
1040     * Make sure the WooCommerce HPOS tables should be enabled for Checksum/Fix.
1041     *
1042     * @see Automattic\Jetpack\SyncActions::initialize_woocommerce
1043     *
1044     * @since 3.3.0
1045     *
1046     * @return bool
1047     */
1048    public static function enable_woocommerce_hpos_tables() {
1049        /**
1050         * On WordPress.com, we can't directly check if the site has support for WooCommerce HPOS tables.
1051         * Having the option to override the functionality here helps with syncing WooCommerce HPOS tables.
1052         *
1053         * @since 3.3.0
1054         *
1055         * @param bool If we should we force-enable WooCommerce HPOS tables support.
1056         */
1057        $force_woocommerce_hpos_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce_hpos', false );
1058
1059        // If we're forcing WooCommerce HPOS tables support, there's no need to check further.
1060        // This is used on WordPress.com.
1061        if ( $force_woocommerce_hpos_support ) {
1062            return true;
1063        }
1064
1065        // If the 'woocommerce_hpos_orders' module is enabled, this means that WooCommerce class exists
1066        // and HPOS is enabled too.
1067        return false !== Sync\Modules::get_module( 'woocommerce_hpos_orders' );
1068    }
1069
1070    /**
1071     * Prepare and append custom columns to the list of columns that we run the checksum on.
1072     *
1073     * @param string|array $additional_columns List of additional columns.
1074     *
1075     * @return void
1076     * @throws Exception When field validation fails.
1077     */
1078    protected function prepare_additional_columns( $additional_columns ) {
1079        /**
1080         * No need to do anything if the parameter is not provided or empty.
1081         */
1082        if ( empty( $additional_columns ) ) {
1083            return;
1084        }
1085
1086        if ( ! is_array( $additional_columns ) ) {
1087            if ( ! is_string( $additional_columns ) ) {
1088                throw new Exception( 'Invalid value for additional fields' );
1089            }
1090
1091            $additional_columns = explode( ',', $additional_columns );
1092        }
1093
1094        /**
1095         * Validate the fields. If any don't conform to the required norms, we will throw an exception and
1096         * halt code here.
1097         */
1098        $this->validate_fields( $additional_columns );
1099
1100        /**
1101         * Assign the fields to the checksum_fields to be used in the checksum later.
1102         *
1103         * We're adding the fields to the rest of the `checksum_fields`, so we don't need
1104         * to implement extra logic just for the additional fields.
1105         */
1106        $this->checksum_fields = array_unique(
1107            array_merge(
1108                $this->checksum_fields,
1109                $additional_columns
1110            )
1111        );
1112    }
1113}