Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.12% covered (danger)
46.12%
220 / 477
10.00% covered (danger)
10.00%
2 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Table_Checksum
46.12% covered (danger)
46.12%
220 / 477
10.00% covered (danger)
10.00%
2 / 20
1506.53
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
94.71% covered (success)
94.71%
215 / 227
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_allowed_tables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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 / 8
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 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 build_filter_statement
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 build_checksum_query
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
156
 get_range_edges
0.00% covered (danger)
0.00%
0 / 58
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 / 18
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_analytics_tables
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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            'wc_order_stats'             => array(
304                'table'                     => "{$wpdb->prefix}wc_order_stats",
305                'range_field'               => 'order_id',
306                'key_fields'                => array( 'order_id' ),
307                'checksum_fields'           => array( 'date_paid', 'date_completed', 'total_sales' ),
308                'checksum_text_fields'      => array( 'status' ),
309                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_analytics_tables',
310            ),
311            'wc_order_product_lookup'    => array(
312                'table'                     => "{$wpdb->prefix}wc_order_product_lookup",
313                'range_field'               => 'order_id',
314                'key_fields'                => array( 'order_id', 'order_item_id' ),
315                'checksum_fields'           => array( 'product_id', 'variation_id', 'product_qty', 'product_net_revenue', 'date_created' ),
316                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_analytics_tables',
317            ),
318            'wc_order_coupon_lookup'     => array(
319                'table'                     => "{$wpdb->prefix}wc_order_coupon_lookup",
320                'range_field'               => 'order_id',
321                'key_fields'                => array( 'order_id', 'coupon_id' ),
322                'checksum_fields'           => array( 'discount_amount', 'date_created' ),
323                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_analytics_tables',
324            ),
325            'wc_order_tax_lookup'        => array(
326                'table'                     => "{$wpdb->prefix}wc_order_tax_lookup",
327                'range_field'               => 'order_id',
328                'key_fields'                => array( 'order_id', 'tax_rate_id' ),
329                'checksum_fields'           => array( 'order_tax', 'total_tax', 'shipping_tax', 'date_created' ),
330                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_analytics_tables',
331            ),
332            'woocommerce_order_items'    => array(
333                'table'                     => "{$wpdb->prefix}woocommerce_order_items",
334                'range_field'               => 'order_item_id',
335                'key_fields'                => array( 'order_item_id' ),
336                'checksum_fields'           => array( 'order_id' ),
337                'checksum_text_fields'      => array( 'order_item_name', 'order_item_type' ),
338                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_tables',
339            ),
340            'woocommerce_order_itemmeta' => array(
341                'table'                     => "{$wpdb->prefix}woocommerce_order_itemmeta",
342                'range_field'               => 'order_item_id',
343                'key_fields'                => array( 'order_item_id', 'meta_key' ),
344                'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
345                'filter_values'             => Sync\Settings::get_allowed_order_itemmeta_structured(),
346                'parent_table'              => 'woocommerce_order_items',
347                'parent_join_field'         => 'order_item_id',
348                'table_join_field'          => 'order_item_id',
349                'is_table_enabled_callback' => function () {
350                    return false !== Sync\Modules::get_module( 'meta' ) && self::enable_woocommerce_tables();
351                },
352            ),
353            'wc_orders'                  => array(
354                'table'                     => "{$wpdb->prefix}wc_orders",
355                'range_field'               => 'id',
356                'key_fields'                => array( 'id' ),
357                'checksum_fields'           => array( 'date_updated_gmt', 'total_amount' ),
358                'checksum_text_fields'      => array( 'type', 'status' ),
359                'filter_values'             => array(
360                    'type'   => array(
361                        'operator' => 'IN',
362                        'values'   => WooCommerce_HPOS_Orders::get_order_types_to_sync( true ),
363                    ),
364                    'status' => array(
365                        'operator' => 'IN',
366                        'values'   => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
367                    ),
368                ),
369                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
370            ),
371            'wc_order_addresses'         => array(
372                'table'                     => "{$wpdb->prefix}wc_order_addresses",
373                'range_field'               => 'order_id',
374                'key_fields'                => array( 'order_id', 'address_type' ),
375                'checksum_text_fields'      => array( 'address_type' ),
376                'parent_table'              => 'wc_orders',
377                'parent_join_field'         => 'id',
378                'table_join_field'          => 'order_id',
379                'filter_values'             => array(),
380                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
381            ),
382            'wc_order_operational_data'  => array(
383                'table'                     => "{$wpdb->prefix}wc_order_operational_data",
384                'range_field'               => 'order_id',
385                'key_fields'                => array( 'order_id' ),
386                'checksum_fields'           => array( 'date_paid_gmt', 'date_completed_gmt' ),
387                'checksum_text_fields'      => array( 'order_key' ),
388                'parent_table'              => 'wc_orders',
389                'parent_join_field'         => 'id',
390                'table_join_field'          => 'order_id',
391                'filter_values'             => array(),
392                'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
393            ),
394            'users'                      => array(
395                'table'                     => $wpdb->users,
396                'range_field'               => 'ID',
397                'key_fields'                => array( 'ID' ),
398                'checksum_text_fields'      => array( 'user_login', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_status', 'display_name' ),
399                'filter_values'             => array(),
400                'is_table_enabled_callback' => function () {
401                    return false !== Sync\Modules::get_module( 'users' );
402                },
403            ),
404
405            /**
406             * Usermeta is a special table, as it needs to use a custom override flow,
407             * as the user roles, capabilities, locale, mime types can be filtered by plugins.
408             * This prevents us from doing a direct comparison in the database.
409             */
410            'usermeta'                   => array(
411                'table'                     => $wpdb->users,
412                /**
413                 * Range field points to ID, which in this case is the `WP_User` ID,
414                 * since we're querying the whole WP_User objects, instead of meta entries in the DB.
415                 */
416                'range_field'               => 'ID',
417                'key_fields'                => array(),
418                'checksum_fields'           => array(),
419                'is_table_enabled_callback' => function () {
420                    return false !== Sync\Modules::get_module( 'users' );
421                },
422            ),
423        );
424    }
425
426    /**
427     * Get allowed table configurations.
428     *
429     * @return array
430     */
431    public static function get_allowed_tables() {
432        return apply_filters( 'jetpack_sync_checksum_allowed_tables', static::get_default_tables() );
433    }
434
435    /**
436     * Prepare field params based off provided configuration.
437     *
438     * @param array $table_configuration The table configuration array.
439     */
440    protected function prepare_fields( $table_configuration ) {
441        $this->key_fields                = $table_configuration['key_fields'];
442        $this->range_field               = $table_configuration['range_field'];
443        $this->checksum_fields           = $table_configuration['checksum_fields'] ?? array();
444        $this->checksum_text_fields      = $table_configuration['checksum_text_fields'] ?? array();
445        $this->filter_values             = $table_configuration['filter_values'] ?? null;
446        $this->additional_filter_sql     = ! empty( $table_configuration['filter_sql'] ) ? $table_configuration['filter_sql'] : '';
447        $this->parent_table              = $table_configuration['parent_table'] ?? null;
448        $this->parent_join_field         = $table_configuration['parent_join_field'] ?? $table_configuration['range_field'];
449        $this->table_join_field          = $table_configuration['table_join_field'] ?? $table_configuration['range_field'];
450        $this->is_table_enabled_callback = $table_configuration['is_table_enabled_callback'] ?? false;
451    }
452
453    /**
454     * Verify provided table name is valid for checksum processing.
455     *
456     * @param string $table Table name to validate.
457     *
458     * @return mixed|string
459     * @throws Exception Throw an exception on validation failure.
460     */
461    protected function validate_table_name( $table ) {
462        if ( empty( $table ) ) {
463            throw new Exception( 'Invalid table name: empty' );
464        }
465
466        if ( ! array_key_exists( $table, $this->allowed_tables ) ) {
467            throw new Exception( "Invalid table name: $table not allowed" );
468        }
469
470        return $this->allowed_tables[ $table ]['table'];
471    }
472
473    /**
474     * Verify provided fields are proper names.
475     *
476     * @param array $fields Array of field names to validate.
477     *
478     * @throws Exception Throw an exception on failure to validate.
479     */
480    protected function validate_fields( $fields ) {
481        foreach ( $fields as $field ) {
482            if ( ! preg_match( '/^[0-9,a-z,A-Z$_]+$/i', $field ) ) {
483                throw new Exception( "Invalid field name: $field is not allowed" );
484            }
485
486            // TODO other verifications of the field names.
487        }
488    }
489
490    /**
491     * Verify the fields exist in the table.
492     *
493     * @param array $fields Array of fields to validate.
494     *
495     * @return bool
496     * @throws Exception Throw an exception on failure to validate.
497     */
498    protected function validate_fields_against_table( $fields ) {
499        global $wpdb;
500
501        $valid_fields = array();
502
503        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
504        $result = $wpdb->get_results( "SHOW COLUMNS FROM {$this->table}", ARRAY_A );
505
506        foreach ( $result as $result_row ) {
507            $valid_fields[] = $result_row['Field'];
508        }
509
510        // Check if the fields are actually contained in the table.
511        foreach ( $fields as $field_to_check ) {
512            if ( ! in_array( $field_to_check, $valid_fields, true ) ) {
513                throw new Exception( "Invalid field name: field '{$field_to_check}' doesn't exist in table {$this->table}" );
514            }
515        }
516
517        return true;
518    }
519
520    /**
521     * Verify the configured fields.
522     *
523     * @throws Exception Throw an exception on failure to validate in the internal functions.
524     */
525    protected function validate_input() {
526        $fields = array_merge( array( $this->range_field ), $this->key_fields, $this->checksum_fields, $this->checksum_text_fields );
527
528        $this->validate_fields( $fields );
529        $this->validate_fields_against_table( $fields );
530    }
531
532    /**
533     * Prepare filter values as SQL statements to be added to the other filters.
534     *
535     * @param array  $filter_values The filter values array.
536     * @param string $table_prefix  If the values are going to be used in a sub-query, add a prefix with the table alias.
537     *
538     * @return array|null
539     */
540    protected function prepare_filter_values_as_sql( $filter_values = array(), $table_prefix = '' ) {
541        global $wpdb;
542
543        if ( ! is_array( $filter_values ) ) {
544            return null;
545        }
546
547        $result = array();
548
549        foreach ( $filter_values as $field => $filter ) {
550            $key = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.' . $field;
551
552            switch ( $filter['operator'] ) {
553                case 'IN':
554                case 'NOT IN':
555                    $filter_values_count = is_countable( $filter['values'] ) ? count( $filter['values'] ) : 0;
556                    $values_placeholders = implode( ',', array_fill( 0, $filter_values_count, '%s' ) );
557                    $statement           = "{$key} {$filter['operator']} ( $values_placeholders )";
558
559                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
560                    $prepared_statement = $wpdb->prepare( $statement, $filter['values'] );
561
562                    $result[] = $prepared_statement;
563                    break;
564            }
565        }
566
567        return $result;
568    }
569
570    /**
571     * Build the filter query baased off range fields and values and the additional sql.
572     *
573     * @param int|null   $range_from    Start of the range.
574     * @param int|null   $range_to      End of the range.
575     * @param array|null $filter_values Additional filter values. Not used at the moment.
576     * @param string     $table_prefix  Table name to be prefixed to the columns. Used in sub-queries where columns can clash.
577     *
578     * @return string
579     */
580    public function build_filter_statement( $range_from = null, $range_to = null, $filter_values = null, $table_prefix = '' ) {
581        global $wpdb;
582
583        // If there is a field prefix that we want to use with table aliases.
584        $parent_prefix = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.';
585
586        /**
587         * Prepare the ranges.
588         */
589
590        $filter_array = array( '1 = 1' );
591        if ( null !== $range_from ) {
592            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
593            $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} >= %d", array( intval( $range_from ) ) );
594        }
595        if ( null !== $range_to ) {
596            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
597            $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} <= %d", array( intval( $range_to ) ) );
598        }
599
600        /**
601         * End prepare the ranges.
602         */
603
604        /**
605         * Prepare data filters.
606         */
607
608        // Default filters.
609        if ( $this->filter_values ) {
610            $prepared_values_statements = $this->prepare_filter_values_as_sql( $this->filter_values, $table_prefix );
611            if ( $prepared_values_statements ) {
612                $filter_array = array_merge( $filter_array, $prepared_values_statements );
613            }
614        }
615
616        // Additional filters.
617        if ( ! empty( $filter_values ) ) {
618            // Prepare filtering.
619            $prepared_values_statements = $this->prepare_filter_values_as_sql( $filter_values, $table_prefix );
620            if ( $prepared_values_statements ) {
621                $filter_array = array_merge( $filter_array, $prepared_values_statements );
622            }
623        }
624
625        // Add any additional filters via direct SQL statement.
626        // Currently used only because we haven't converted all filtering to happen via `filter_values`.
627        // This SQL is NOT prefixed and column clashes can occur when used in sub-queries.
628        if ( $this->additional_filter_sql ) {
629            $filter_array[] = $this->additional_filter_sql;
630        }
631
632        /**
633         * End prepare data filters.
634         */
635        return implode( ' AND ', $filter_array );
636    }
637
638    /**
639     * Returns the checksum query. All validation of fields and configurations are expected to occur prior to usage.
640     *
641     * @param int|null   $range_from      The start of the range.
642     * @param int|null   $range_to        The end of the range.
643     * @param array|null $filter_values   Additional filter values. Not used at the moment.
644     * @param bool       $granular_result If the function should return a granular result.
645     *
646     * @return string
647     *
648     * @throws Exception Throws an exception if validation fails in the internal function calls.
649     */
650    protected function build_checksum_query( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false ) {
651        global $wpdb;
652
653        // Escape the salt.
654        $salt = $wpdb->prepare( '%s', $this->salt );
655
656        // Prepare the compound key.
657        $key_fields = array();
658
659        // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
660        foreach ( $this->key_fields as $field ) {
661            $key_fields[] = $this->table . '.' . $field;
662        }
663
664        $key_fields = implode( ',', $key_fields );
665
666        // Prepare the checksum fields.
667        $checksum_fields = array();
668        // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
669        foreach ( $this->checksum_fields as $field ) {
670            $checksum_fields[] = $this->table . '.' . $field;
671        }
672        // Apply latin1 conversion if enabled.
673        if ( $this->perform_text_conversion ) {
674            // Convert text fields to allow for encoding discrepancies as WP.com is latin1.
675            foreach ( $this->checksum_text_fields as $field ) {
676                $checksum_fields[] = 'CONVERT(' . $this->table . '.' . $field . ' using latin1 )';
677            }
678        } else {
679            // Conversion disabled, default to table prefixing.
680            foreach ( $this->checksum_text_fields as $field ) {
681                $checksum_fields[] = $this->table . '.' . $field;
682            }
683        }
684
685        $checksum_fields_string = implode( ',', array_merge( $checksum_fields, array( $salt ) ) );
686
687        $additional_fields = '';
688        if ( $granular_result ) {
689            // TODO uniq the fields as sometimes(most) range_index is the key and there's no need to select the same field twice.
690            $additional_fields = "
691                {$this->table}.{$this->range_field} as range_index,
692                {$key_fields},
693            ";
694        }
695
696        $filter_stamenet = $this->build_filter_statement( $range_from, $range_to, $filter_values );
697
698        $join_statement = '';
699        // On WPCOM the checksum comparison does not use the parent table INNER JOIN.
700        // WPCOM sets parent_table in its config solely for the count optimization in
701        // get_range_edges(), so we skip the JOIN to avoid query differences.
702        if ( $this->parent_table && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
703            $parent_table_obj    = new Table_Checksum( $this->parent_table );
704            $parent_filter_query = $parent_table_obj->build_filter_statement( null, null, null, 'parent_table' );
705
706            // It is possible to have the GROUP By cause multiple rows to be returned for the same row for term_taxonomy.
707            // To get distinct entries we use a correlatd subquery back on the parent table using the primary key.
708            $additional_unique_clause = '';
709            if ( 'term_taxonomy' === $this->parent_table ) {
710                $additional_unique_clause = "
711                AND parent_table.{$parent_table_obj->range_field} = (
712                SELECT min( parent_table_cs.{$parent_table_obj->range_field} )
713                        FROM {$parent_table_obj->table} as parent_table_cs
714                        WHERE parent_table_cs.{$this->parent_join_field} = {$this->table}.{$this->table_join_field}
715                    )
716                ";
717            }
718
719            $join_statement = "
720                INNER JOIN {$parent_table_obj->table} as parent_table
721                ON (
722                    {$this->table}.{$this->table_join_field} = parent_table.{$this->parent_join_field}
723                    AND {$parent_filter_query}
724                    $additional_unique_clause
725                )
726            ";
727        }
728
729        $query = "
730            SELECT
731                {$additional_fields}
732                SUM(
733                    CRC32(
734                        CONCAT_WS( '#', {$salt}, {$checksum_fields_string} )
735                    )
736                )  AS checksum
737             FROM
738                {$this->table}
739                {$join_statement}
740             WHERE
741                {$filter_stamenet}
742        ";
743
744        /**
745         * We need the GROUP BY only for compound keys.
746         */
747        if ( $granular_result ) {
748            $query .= "
749                GROUP BY {$key_fields}
750                LIMIT 9999999
751            ";
752        }
753
754        return $query;
755    }
756
757    /**
758     * Obtain the min-max values (edges) of the range.
759     *
760     * @param int|null $range_from The start of the range.
761     * @param int|null $range_to   The end of the range.
762     * @param int|null $limit      How many values to return.
763     *
764     * @return array|object|void
765     * @throws Exception Throws an exception if validation fails on the internal function calls.
766     */
767    public function get_range_edges( $range_from = null, $range_to = null, $limit = null ) {
768        global $wpdb;
769
770        $this->validate_fields( array( $this->range_field ) );
771
772        // Performance :: For meta tables (postmeta, commentmeta, termmeta, woocommerce_order_itemmeta)
773        // we strip the filter_values (e.g. meta_key whitelist) when building the range edges query.
774        // These filters cause non-performant queries that can timeout on large tables.
775        // The actual data filtering happens during checksum calculation â€” via the filter_values
776        // WHERE clause and, when enabled, the parent table INNER JOIN.
777        $is_meta_table = in_array(
778            $this->table,
779            array( $wpdb->postmeta, $wpdb->commentmeta, $wpdb->termmeta, "{$wpdb->prefix}woocommerce_order_itemmeta" ),
780            true
781        );
782        $filter_values = $this->filter_values;
783        if ( $is_meta_table ) {
784            $this->filter_values = null;
785        }
786
787        // `trim()` to make sure we don't add the statement if it's empty.
788        $filters = trim( $this->build_filter_statement( $range_from, $range_to ) );
789
790        // Restore filter values.
791        if ( $is_meta_table ) {
792            $this->filter_values = $filter_values;
793        }
794
795        $filter_statement = '';
796        if ( ! empty( $filters ) ) {
797            $filter_statement = "
798                WHERE
799                    {$filters}
800            ";
801        }
802
803        // Only make the distinct count when we know there can be multiple entries for the range column.
804        $distinct_count = '';
805        if ( count( $this->key_fields ) > 1 || $wpdb->terms === $this->table || $wpdb->term_relationships === $this->table ) {
806            $distinct_count = 'DISTINCT';
807        }
808
809        $query = "
810            SELECT
811                   MIN({$this->range_field}) as min_range,
812                   MAX({$this->range_field}) as max_range,
813                   COUNT( {$distinct_count} {$this->range_field}) as item_count
814            FROM
815        ";
816
817        /**
818         * If `$limit` is not specified, we can directly use the table.
819         */
820        if ( ! $limit ) {
821            // For tables that would use COUNT(DISTINCT), avoid the expensive full table scan
822            // by using the parent table's count instead. Only for full-table calls â€” sub-range
823            // calls need the actual COUNT(DISTINCT) scoped to the range, and those are cheap
824            // because the WHERE clause limits the scan.
825            if ( $distinct_count && null === $range_from && null === $range_to ) {
826                $parent_count = $this->get_parent_table_count();
827                if ( (int) $parent_count > 0 ) {
828                    $min_max_query = "
829                        SELECT
830                            MIN({$this->range_field}) as min_range,
831                            MAX({$this->range_field}) as max_range
832                        FROM
833                            {$this->table}
834                            {$filter_statement}
835                    ";
836
837                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
838                    $result = $wpdb->get_row( $min_max_query, ARRAY_A );
839
840                    if ( $result && is_array( $result ) ) {
841                        $result['item_count']                    = $parent_count;
842                        self::$range_edges_cache[ $this->table ] = $result;
843                        return $result;
844                    }
845                }
846            }
847
848            $query .= "
849                {$this->table}
850                {$filter_statement}
851            ";
852        } else {
853            /**
854             * If there is `$limit` specified, we can't directly use `MIN/MAX()` as they don't work with `LIMIT`.
855             * That's why we will alter the query for this case.
856             */
857            $limit = intval( $limit );
858
859            $query .= "
860                (
861                    SELECT
862                        {$distinct_count} {$this->range_field}
863                    FROM
864                        {$this->table}
865                        {$filter_statement}
866                    ORDER BY
867                        {$this->range_field} ASC
868                    LIMIT {$limit}
869                ) as ids_query
870            ";
871        }
872
873        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
874        $result = $wpdb->get_row( $query, ARRAY_A );
875
876        if ( ! $result || ! is_array( $result ) ) {
877            throw new Exception( 'Unable to get range edges' );
878        }
879
880        // Cache full-range results so child meta tables can reuse the parent's count.
881        // Only cache when no range constraints â€” sub-range counts would pollute the cache.
882        if ( ! $limit && null === $range_from && null === $range_to ) {
883            self::$range_edges_cache[ $this->table ] = $result;
884        }
885
886        return $result;
887    }
888
889    /**
890     * Static cache for range edge results, keyed by table name.
891     *
892     * When checksum_all() processes tables sequentially, the parent table's
893     * get_range_edges() result is cached so child tables can reuse the
894     * item_count without re-querying.
895     *
896     * @var array
897     */
898    private static $range_edges_cache = array();
899
900    /**
901     * Reset the static range edges cache.
902     *
903     * Should be called when the underlying data changes and cached
904     * counts may be stale (e.g. between test runs).
905     */
906    public static function reset_range_edges_cache() {
907        self::$range_edges_cache = array();
908    }
909
910    /**
911     * Get the row count from the parent table as an approximate item count.
912     *
913     * For tables with compound keys or non-unique range fields, COUNT(DISTINCT range_field)
914     * causes expensive full table scans. Since item_count is only used for bucket sizing
915     * in checksum_histogram(), the parent table's row count is an acceptable approximation.
916     * In typical cases the parent count >= the distinct child count, producing slightly
917     * more (smaller) buckets. The caller guards against a zero parent count (e.g. orphaned
918     * child rows) by falling back to the original COUNT(DISTINCT) query.
919     *
920     * Returns false when the parent table's count is not a reliable proxy (e.g.
921     * term_taxonomy, whose count does not correlate with distinct range_field values
922     * in terms, termmeta, or term_relationships).
923     *
924     * Uses a static cache so that if the parent table was already processed
925     * (e.g. posts before postmeta in checksum_all), no additional query is needed.
926     *
927     * @return int|false The parent table row count, or false if not applicable.
928     */
929    private function get_parent_table_count() {
930        if ( ! $this->parent_table ) {
931            return false;
932        }
933
934        // term_taxonomy's count is not a reliable proxy for the distinct range_field
935        // values in terms, termmeta, or term_relationships.
936        if ( 'term_taxonomy' === $this->parent_table ) {
937            return false;
938        }
939
940        try {
941            $parent_table_obj = new Table_Checksum( $this->parent_table );
942        } catch ( Exception $e ) {
943            return false;
944        }
945
946        // Check static cache first â€” the parent may have been queried already
947        // (e.g. posts processed before postmeta in checksum_all).
948        if ( isset( self::$range_edges_cache[ $parent_table_obj->table ] ) ) {
949            return (int) self::$range_edges_cache[ $parent_table_obj->table ]['item_count'];
950        }
951
952        // Query the parent table's range edges. For single-key parent tables this is
953        // a simple COUNT (no DISTINCT), so it's fast.
954        try {
955            $parent_range = $parent_table_obj->get_range_edges();
956
957            if ( is_array( $parent_range ) && isset( $parent_range['item_count'] ) ) {
958                return (int) $parent_range['item_count'];
959            }
960
961            return false;
962        } catch ( Exception $e ) {
963            return false;
964        }
965    }
966
967    /**
968     * Update the results to have key/checksum format.
969     *
970     * @param array $results Prepare the results for output of granular results.
971     */
972    protected function prepare_results_for_output( &$results ) {
973        // get the compound key.
974        // only return range and compound key for granular results.
975
976        $return_value = array();
977
978        foreach ( $results as &$result ) {
979            // Working on reference to save memory here.
980
981            $key = array();
982            foreach ( $this->key_fields as $field ) {
983                $key[] = $result[ $field ];
984            }
985
986            $return_value[ implode( '-', $key ) ] = $result['checksum'];
987        }
988
989        return $return_value;
990    }
991
992    /**
993     * Calculate the checksum based on provided range and filters.
994     *
995     * @param int|null   $range_from          The start of the range.
996     * @param int|null   $range_to            The end of the range.
997     * @param array|null $filter_values       Additional filter values. Not used at the moment.
998     * @param bool       $granular_result     If the returned result should be granular or only the checksum.
999     * @param bool       $simple_return_value If we want to use a simple return value for non-granular results (return only the checksum, without wrappers).
1000     *
1001     * @return array|mixed|object|WP_Error|null
1002     */
1003    public function calculate_checksum( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false, $simple_return_value = true ) {
1004
1005        if ( ! Sync\Settings::is_checksum_enabled() ) {
1006            return new WP_Error( 'checksum_disabled', 'Checksums are currently disabled.' );
1007        }
1008
1009        try {
1010            $this->validate_input();
1011        } catch ( Exception $ex ) {
1012            return new WP_Error( 'invalid_input', $ex->getMessage() );
1013        }
1014
1015        $query = $this->build_checksum_query( $range_from, $range_to, $filter_values, $granular_result );
1016
1017        global $wpdb;
1018
1019        if ( ! $granular_result ) {
1020            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1021            $result = $wpdb->get_row( $query, ARRAY_A );
1022
1023            if ( ! is_array( $result ) ) {
1024                return new WP_Error( 'invalid_query', "Result wasn't an array" );
1025            }
1026
1027            if ( $simple_return_value ) {
1028                return $result['checksum'];
1029            }
1030
1031            return array(
1032                'range'    => $range_from . '-' . $range_to,
1033                'checksum' => $result['checksum'],
1034            );
1035        } else {
1036            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1037            $result = $wpdb->get_results( $query, ARRAY_A );
1038            return $this->prepare_results_for_output( $result );
1039        }
1040    }
1041
1042    /**
1043     * Make sure the WooCommerce tables should be enabled for Checksum/Fix.
1044     *
1045     * @return bool
1046     */
1047    public static function enable_woocommerce_tables() {
1048        /**
1049         * On WordPress.com, we can't directly check if the site has support for WooCommerce.
1050         * Having the option to override the functionality here helps with syncing WooCommerce tables.
1051         *
1052         * @since 10.1
1053         *
1054         * @param bool If we should we force-enable WooCommerce tables support.
1055         */
1056        $force_woocommerce_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce', false );
1057
1058        // If we're forcing WooCommerce tables support, there's no need to check further.
1059        // This is used on WordPress.com.
1060        if ( $force_woocommerce_support ) {
1061            return true;
1062        }
1063
1064        // If the 'woocommerce' module is enabled, this means that WooCommerce class exists.
1065        return false !== Sync\Modules::get_module( 'woocommerce' );
1066    }
1067
1068    /**
1069     * Make sure the WooCommerce Analytics tables should be enabled for Checksum/Fix.
1070     *
1071     * @since 5.1.0
1072     *
1073     * @return bool
1074     */
1075    public static function enable_woocommerce_analytics_tables() {
1076        /**
1077         * On WordPress.com, WooCommerce runtime classes and Sync modules are not
1078         * available while comparing table checksums. This override allows the
1079         * Analytics tables to be used there.
1080         *
1081         * @since 5.1.0
1082         *
1083         * @param bool $force_woocommerce_analytics_support Whether to force-enable WooCommerce Analytics table support.
1084         */
1085        $force_woocommerce_analytics_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce_analytics', false );
1086
1087        if ( $force_woocommerce_analytics_support ) {
1088            return true;
1089        }
1090
1091        return false !== Sync\Modules::get_module( 'woocommerce_analytics' );
1092    }
1093
1094    /**
1095     * Make sure the WooCommerce HPOS tables should be enabled for Checksum/Fix.
1096     *
1097     * @see Automattic\Jetpack\SyncActions::initialize_woocommerce
1098     *
1099     * @since 3.3.0
1100     *
1101     * @return bool
1102     */
1103    public static function enable_woocommerce_hpos_tables() {
1104        /**
1105         * On WordPress.com, we can't directly check if the site has support for WooCommerce HPOS tables.
1106         * Having the option to override the functionality here helps with syncing WooCommerce HPOS tables.
1107         *
1108         * @since 3.3.0
1109         *
1110         * @param bool If we should we force-enable WooCommerce HPOS tables support.
1111         */
1112        $force_woocommerce_hpos_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce_hpos', false );
1113
1114        // If we're forcing WooCommerce HPOS tables support, there's no need to check further.
1115        // This is used on WordPress.com.
1116        if ( $force_woocommerce_hpos_support ) {
1117            return true;
1118        }
1119
1120        // If the 'woocommerce_hpos_orders' module is enabled, this means that WooCommerce class exists
1121        // and HPOS is enabled too.
1122        return false !== Sync\Modules::get_module( 'woocommerce_hpos_orders' );
1123    }
1124
1125    /**
1126     * Prepare and append custom columns to the list of columns that we run the checksum on.
1127     *
1128     * @param string|array $additional_columns List of additional columns.
1129     *
1130     * @return void
1131     * @throws Exception When field validation fails.
1132     */
1133    protected function prepare_additional_columns( $additional_columns ) {
1134        /**
1135         * No need to do anything if the parameter is not provided or empty.
1136         */
1137        if ( empty( $additional_columns ) ) {
1138            return;
1139        }
1140
1141        if ( ! is_array( $additional_columns ) ) {
1142            if ( ! is_string( $additional_columns ) ) {
1143                throw new Exception( 'Invalid value for additional fields' );
1144            }
1145
1146            $additional_columns = explode( ',', $additional_columns );
1147        }
1148
1149        /**
1150         * Validate the fields. If any don't conform to the required norms, we will throw an exception and
1151         * halt code here.
1152         */
1153        $this->validate_fields( $additional_columns );
1154
1155        /**
1156         * Assign the fields to the checksum_fields to be used in the checksum later.
1157         *
1158         * We're adding the fields to the rest of the `checksum_fields`, so we don't need
1159         * to implement extra logic just for the additional fields.
1160         */
1161        $this->checksum_fields = array_unique(
1162            array_merge(
1163                $this->checksum_fields,
1164                $additional_columns
1165            )
1166        );
1167    }
1168}