PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-sync / src / replicastore / class-table-checksum.php

class-table-checksum.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php

1,169 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Table Checksums Class.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8 namespace Automattic\Jetpack\Sync\Replicastore;
9
10 use Automattic\Jetpack\Sync;
11 use Automattic\Jetpack\Sync\Modules\WooCommerce_HPOS_Orders;
12 use Exception;
13 use 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 */
19 class 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 }
1169