summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php')
-rw-r--r--plugins/jetpack/jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php826
1 files changed, 826 insertions, 0 deletions
diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php
new file mode 100644
index 00000000..a62050eb
--- /dev/null
+++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-sync/src/replicastore/class-table-checksum.php
@@ -0,0 +1,826 @@
+<?php
+/**
+ * Table Checksums Class.
+ *
+ * @package automattic/jetpack-sync
+ */
+
+namespace Automattic\Jetpack\Sync\Replicastore;
+
+use Automattic\Jetpack\Sync;
+use Exception;
+use WP_Error;
+
+// TODO add rest endpoints to work with this, hopefully in the same folder.
+/**
+ * Class to handle Table Checksums.
+ */
+class Table_Checksum {
+
+ /**
+ * Table to be checksummed.
+ *
+ * @var string
+ */
+ public $table = '';
+
+ /**
+ * Table Checksum Configuration.
+ *
+ * @var array
+ */
+ public $table_configuration = array();
+
+ /**
+ * Perform Text Conversion to latin1.
+ *
+ * @var boolean
+ */
+ protected $perform_text_conversion = false;
+
+ /**
+ * Field to be used for range queries.
+ *
+ * @var string
+ */
+ public $range_field = '';
+
+ /**
+ * ID Field(s) to be used.
+ *
+ * @var array
+ */
+ public $key_fields = array();
+
+ /**
+ * Field(s) to be used in generating the checksum value.
+ *
+ * @var array
+ */
+ public $checksum_fields = array();
+
+ /**
+ * Field(s) to be used in generating the checksum value that need latin1 conversion.
+ *
+ * @var array
+ */
+ public $checksum_text_fields = array();
+
+ /**
+ * Default filter values for the table
+ *
+ * @var array
+ */
+ public $filter_values = array();
+
+ /**
+ * SQL Query to be used to filter results (allow/disallow).
+ *
+ * @var string
+ */
+ public $additional_filter_sql = '';
+
+ /**
+ * Default Checksum Table Configurations.
+ *
+ * @var array
+ */
+ public $default_tables = array();
+
+ /**
+ * Salt to be used when generating checksum.
+ *
+ * @var string
+ */
+ public $salt = '';
+
+ /**
+ * Tables which are allowed to be checksummed.
+ *
+ * @var string
+ */
+ public $allowed_tables = array();
+
+ /**
+ * If the table has a "parent" table that it's related to.
+ *
+ * @var mixed|null
+ */
+ protected $parent_table = null;
+
+ /**
+ * What field to use for the parent table join, if it has a "parent" table.
+ *
+ * @var mixed|null
+ */
+ protected $parent_join_field = null;
+
+ /**
+ * What field to use for the table join, if it has a "parent" table.
+ *
+ * @var mixed|null
+ */
+ protected $table_join_field = null;
+
+ /**
+ * Some tables might not exist on the remote, and we want to verify they exist, before trying to query them.
+ *
+ * @var callable
+ */
+ protected $is_table_enabled_callback = false;
+
+ /**
+ * Table_Checksum constructor.
+ *
+ * @param string $table The table to calculate checksums for.
+ * @param string $salt Optional salt to add to the checksum.
+ * @param boolean $perform_text_conversion If text fields should be latin1 converted.
+ *
+ * @throws Exception Throws exception from inner functions.
+ */
+ public function __construct( $table, $salt = null, $perform_text_conversion = false ) {
+
+ if ( ! Sync\Settings::is_checksum_enabled() ) {
+ throw new Exception( 'Checksums are currently disabled.' );
+ }
+
+ $this->salt = $salt;
+
+ $this->default_tables = $this->get_default_tables();
+
+ $this->perform_text_conversion = $perform_text_conversion;
+
+ // TODO change filters to allow the array format.
+ // TODO add get_fields or similar method to get things out of the table.
+ // TODO extract this configuration in a better way, still make it work with `$wpdb` names.
+ // TODO take over the replicastore functions and move them over to this class.
+ // TODO make the API work.
+
+ $this->allowed_tables = apply_filters( 'jetpack_sync_checksum_allowed_tables', $this->default_tables );
+
+ $this->table = $this->validate_table_name( $table );
+ $this->table_configuration = $this->allowed_tables[ $table ];
+
+ $this->prepare_fields( $this->table_configuration );
+
+ // Run any callbacks to check if a table is enabled or not.
+ if (
+ is_callable( $this->is_table_enabled_callback )
+ && ! call_user_func( $this->is_table_enabled_callback, $table )
+ ) {
+ throw new Exception( "Unable to use table name: $table" );
+ }
+ }
+
+ /**
+ * Get Default Table configurations.
+ *
+ * @return array
+ */
+ protected function get_default_tables() {
+ global $wpdb;
+
+ return array(
+ 'posts' => array(
+ 'table' => $wpdb->posts,
+ 'range_field' => 'ID',
+ 'key_fields' => array( 'ID' ),
+ 'checksum_fields' => array( 'post_modified_gmt' ),
+ 'filter_values' => Sync\Settings::get_disallowed_post_types_structured(),
+ ),
+ 'postmeta' => array(
+ 'table' => $wpdb->postmeta,
+ 'range_field' => 'post_id',
+ 'key_fields' => array( 'post_id', 'meta_key' ),
+ 'checksum_text_fields' => array( 'meta_key', 'meta_value' ),
+ 'filter_values' => Sync\Settings::get_allowed_post_meta_structured(),
+ 'parent_table' => 'posts',
+ 'parent_join_field' => 'ID',
+ 'table_join_field' => 'post_id',
+ ),
+ 'comments' => array(
+ 'table' => $wpdb->comments,
+ 'range_field' => 'comment_ID',
+ 'key_fields' => array( 'comment_ID' ),
+ 'checksum_fields' => array( 'comment_date_gmt' ),
+ 'filter_values' => array(
+ 'comment_type' => array(
+ 'operator' => 'IN',
+ 'values' => apply_filters(
+ 'jetpack_sync_whitelisted_comment_types',
+ array( '', 'comment', 'trackback', 'pingback', 'review' )
+ ),
+ ),
+ 'comment_approved' => array(
+ 'operator' => 'NOT IN',
+ 'values' => array( 'spam' ),
+ ),
+ ),
+ ),
+ 'commentmeta' => array(
+ 'table' => $wpdb->commentmeta,
+ 'range_field' => 'comment_id',
+ 'key_fields' => array( 'comment_id', 'meta_key' ),
+ 'checksum_text_fields' => array( 'meta_key', 'meta_value' ),
+ 'filter_values' => Sync\Settings::get_allowed_comment_meta_structured(),
+ 'parent_table' => 'comments',
+ 'parent_join_field' => 'comment_ID',
+ 'table_join_field' => 'comment_id',
+ ),
+ 'terms' => array(
+ 'table' => $wpdb->terms,
+ 'range_field' => 'term_id',
+ 'key_fields' => array( 'term_id' ),
+ 'checksum_fields' => array( 'term_id' ),
+ 'checksum_text_fields' => array( 'name', 'slug' ),
+ 'parent_table' => 'term_taxonomy',
+ ),
+ 'termmeta' => array(
+ 'table' => $wpdb->termmeta,
+ 'range_field' => 'term_id',
+ 'key_fields' => array( 'term_id', 'meta_key' ),
+ 'checksum_text_fields' => array( 'meta_key', 'meta_value' ),
+ 'parent_table' => 'term_taxonomy',
+ ),
+ 'term_relationships' => array(
+ 'table' => $wpdb->term_relationships,
+ 'range_field' => 'object_id',
+ 'key_fields' => array( 'object_id' ),
+ 'checksum_fields' => array( 'object_id', 'term_taxonomy_id' ),
+ 'parent_table' => 'term_taxonomy',
+ 'parent_join_field' => 'term_taxonomy_id',
+ 'table_join_field' => 'term_taxonomy_id',
+ ),
+ 'term_taxonomy' => array(
+ 'table' => $wpdb->term_taxonomy,
+ 'range_field' => 'term_taxonomy_id',
+ 'key_fields' => array( 'term_taxonomy_id' ),
+ 'checksum_fields' => array( 'term_taxonomy_id', 'term_id', 'parent' ),
+ 'checksum_text_fields' => array( 'taxonomy', 'description' ),
+ 'filter_values' => Sync\Settings::get_allowed_taxonomies_structured(),
+ ),
+ 'links' => $wpdb->links, // TODO describe in the array format or add exceptions.
+ 'options' => $wpdb->options, // TODO describe in the array format or add exceptions.
+ 'woocommerce_order_items' => array(
+ 'table' => "{$wpdb->prefix}woocommerce_order_items",
+ 'range_field' => 'order_item_id',
+ 'key_fields' => array( 'order_item_id' ),
+ 'checksum_fields' => array( 'order_id' ),
+ 'checksum_text_fields' => array( 'order_item_name', 'order_item_type' ),
+ 'is_table_enabled_callback' => array( $this, 'enable_woocommerce_tables' ),
+ ),
+ 'woocommerce_order_itemmeta' => array(
+ 'table' => "{$wpdb->prefix}woocommerce_order_itemmeta",
+ 'range_field' => 'order_item_id',
+ 'key_fields' => array( 'order_item_id', 'meta_key' ),
+ 'checksum_text_fields' => array( 'meta_key', 'meta_value' ),
+ 'filter_values' => Sync\Settings::get_allowed_order_itemmeta_structured(),
+ 'parent_table' => 'woocommerce_order_items',
+ 'parent_join_field' => 'order_item_id',
+ 'table_join_field' => 'order_item_id',
+ 'is_table_enabled_callback' => array( $this, 'enable_woocommerce_tables' ),
+ ),
+ 'users' => array(
+ 'table' => $wpdb->users,
+ 'range_field' => 'ID',
+ 'key_fields' => array( 'ID' ),
+ 'checksum_text_fields' => array( 'user_login', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_status', 'display_name' ),
+ 'filter_values' => array(),
+ ),
+
+ /**
+ * Usermeta is a special table, as it needs to use a custom override flow,
+ * as the user roles, capabilities, locale, mime types can be filtered by plugins.
+ * This prevents us from doing a direct comparison in the database.
+ */
+ 'usermeta' => array(
+ 'table' => $wpdb->users,
+ /**
+ * Range field points to ID, which in this case is the `WP_User` ID,
+ * since we're querying the whole WP_User objects, instead of meta entries in the DB.
+ */
+ 'range_field' => 'ID',
+ 'key_fields' => array(),
+ 'checksum_fields' => array(),
+ ),
+ );
+ }
+
+ /**
+ * Prepare field params based off provided configuration.
+ *
+ * @param array $table_configuration The table configuration array.
+ */
+ protected function prepare_fields( $table_configuration ) {
+ $this->key_fields = $table_configuration['key_fields'];
+ $this->range_field = $table_configuration['range_field'];
+ $this->checksum_fields = isset( $table_configuration['checksum_fields'] ) ? $table_configuration['checksum_fields'] : array();
+ $this->checksum_text_fields = isset( $table_configuration['checksum_text_fields'] ) ? $table_configuration['checksum_text_fields'] : array();
+ $this->filter_values = isset( $table_configuration['filter_values'] ) ? $table_configuration['filter_values'] : null;
+ $this->additional_filter_sql = ! empty( $table_configuration['filter_sql'] ) ? $table_configuration['filter_sql'] : '';
+ $this->parent_table = isset( $table_configuration['parent_table'] ) ? $table_configuration['parent_table'] : null;
+ $this->parent_join_field = isset( $table_configuration['parent_join_field'] ) ? $table_configuration['parent_join_field'] : $table_configuration['range_field'];
+ $this->table_join_field = isset( $table_configuration['table_join_field'] ) ? $table_configuration['table_join_field'] : $table_configuration['range_field'];
+ $this->is_table_enabled_callback = isset( $table_configuration['is_table_enabled_callback'] ) ? $table_configuration['is_table_enabled_callback'] : false;
+ }
+
+ /**
+ * Verify provided table name is valid for checksum processing.
+ *
+ * @param string $table Table name to validate.
+ *
+ * @return mixed|string
+ * @throws Exception Throw an exception on validation failure.
+ */
+ protected function validate_table_name( $table ) {
+ if ( empty( $table ) ) {
+ throw new Exception( 'Invalid table name: empty' );
+ }
+
+ if ( ! array_key_exists( $table, $this->allowed_tables ) ) {
+ throw new Exception( "Invalid table name: $table not allowed" );
+ }
+
+ return $this->allowed_tables[ $table ]['table'];
+ }
+
+ /**
+ * Verify provided fields are proper names.
+ *
+ * @param array $fields Array of field names to validate.
+ *
+ * @throws Exception Throw an exception on failure to validate.
+ */
+ protected function validate_fields( $fields ) {
+ foreach ( $fields as $field ) {
+ if ( ! preg_match( '/^[0-9,a-z,A-Z$_]+$/i', $field ) ) {
+ throw new Exception( "Invalid field name: $field is not allowed" );
+ }
+
+ // TODO other verifications of the field names.
+ }
+ }
+
+ /**
+ * Verify the fields exist in the table.
+ *
+ * @param array $fields Array of fields to validate.
+ *
+ * @return bool
+ * @throws Exception Throw an exception on failure to validate.
+ */
+ protected function validate_fields_against_table( $fields ) {
+ global $wpdb;
+
+ $valid_fields = array();
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $result = $wpdb->get_results( "SHOW COLUMNS FROM {$this->table}", ARRAY_A );
+
+ foreach ( $result as $result_row ) {
+ $valid_fields[] = $result_row['Field'];
+ }
+
+ // Check if the fields are actually contained in the table.
+ foreach ( $fields as $field_to_check ) {
+ if ( ! in_array( $field_to_check, $valid_fields, true ) ) {
+ throw new Exception( "Invalid field name: field '{$field_to_check}' doesn't exist in table {$this->table}" );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Verify the configured fields.
+ *
+ * @throws Exception Throw an exception on failure to validate in the internal functions.
+ */
+ protected function validate_input() {
+ $fields = array_merge( array( $this->range_field ), $this->key_fields, $this->checksum_fields, $this->checksum_text_fields );
+
+ $this->validate_fields( $fields );
+ $this->validate_fields_against_table( $fields );
+ }
+
+ /**
+ * Prepare filter values as SQL statements to be added to the other filters.
+ *
+ * @param array $filter_values The filter values array.
+ * @param string $table_prefix If the values are going to be used in a sub-query, add a prefix with the table alias.
+ *
+ * @return array|null
+ */
+ protected function prepare_filter_values_as_sql( $filter_values = array(), $table_prefix = '' ) {
+ global $wpdb;
+
+ if ( ! is_array( $filter_values ) ) {
+ return null;
+ }
+
+ $result = array();
+
+ foreach ( $filter_values as $field => $filter ) {
+ $key = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.' . $field;
+
+ switch ( $filter['operator'] ) {
+ case 'IN':
+ case 'NOT IN':
+ $values_placeholders = implode( ',', array_fill( 0, count( $filter['values'] ), '%s' ) );
+ $statement = "{$key} {$filter['operator']} ( $values_placeholders )";
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $prepared_statement = $wpdb->prepare( $statement, $filter['values'] );
+
+ $result[] = $prepared_statement;
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Build the filter query baased off range fields and values and the additional sql.
+ *
+ * @param int|null $range_from Start of the range.
+ * @param int|null $range_to End of the range.
+ * @param array|null $filter_values Additional filter values. Not used at the moment.
+ * @param string $table_prefix Table name to be prefixed to the columns. Used in sub-queries where columns can clash.
+ *
+ * @return string
+ */
+ public function build_filter_statement( $range_from = null, $range_to = null, $filter_values = null, $table_prefix = '' ) {
+ global $wpdb;
+
+ // If there is a field prefix that we want to use with table aliases.
+ $parent_prefix = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.';
+
+ /**
+ * Prepare the ranges.
+ */
+
+ $filter_array = array( '1 = 1' );
+ if ( null !== $range_from ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} >= %d", array( intval( $range_from ) ) );
+ }
+ if ( null !== $range_to ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} <= %d", array( intval( $range_to ) ) );
+ }
+
+ /**
+ * End prepare the ranges.
+ */
+
+ /**
+ * Prepare data filters.
+ */
+
+ // Default filters.
+ if ( $this->filter_values ) {
+ $prepared_values_statements = $this->prepare_filter_values_as_sql( $this->filter_values, $table_prefix );
+ if ( $prepared_values_statements ) {
+ $filter_array = array_merge( $filter_array, $prepared_values_statements );
+ }
+ }
+
+ // Additional filters.
+ if ( ! empty( $filter_values ) ) {
+ // Prepare filtering.
+ $prepared_values_statements = $this->prepare_filter_values_as_sql( $filter_values, $table_prefix );
+ if ( $prepared_values_statements ) {
+ $filter_array = array_merge( $filter_array, $prepared_values_statements );
+ }
+ }
+
+ // Add any additional filters via direct SQL statement.
+ // Currently used only because we haven't converted all filtering to happen via `filter_values`.
+ // This SQL is NOT prefixed and column clashes can occur when used in sub-queries.
+ if ( $this->additional_filter_sql ) {
+ $filter_array[] = $this->additional_filter_sql;
+ }
+
+ /**
+ * End prepare data filters.
+ */
+ return implode( ' AND ', $filter_array );
+ }
+
+ /**
+ * Returns the checksum query. All validation of fields and configurations are expected to occur prior to usage.
+ *
+ * @param int|null $range_from The start of the range.
+ * @param int|null $range_to The end of the range.
+ * @param array|null $filter_values Additional filter values. Not used at the moment.
+ * @param bool $granular_result If the function should return a granular result.
+ *
+ * @return string
+ *
+ * @throws Exception Throws an exception if validation fails in the internal function calls.
+ */
+ protected function build_checksum_query( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false ) {
+ global $wpdb;
+
+ // Escape the salt.
+ $salt = $wpdb->prepare( '%s', $this->salt );
+
+ // Prepare the compound key.
+ $key_fields = array();
+
+ // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
+ foreach ( $this->key_fields as $field ) {
+ $key_fields[] = $this->table . '.' . $field;
+ }
+
+ $key_fields = implode( ',', $key_fields );
+
+ // Prepare the checksum fields.
+ $checksum_fields = array();
+ // Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
+ foreach ( $this->checksum_fields as $field ) {
+ $checksum_fields[] = $this->table . '.' . $field;
+ }
+ // Apply latin1 conversion if enabled.
+ if ( $this->perform_text_conversion ) {
+ // Convert text fields to allow for encoding discrepancies as WP.com is latin1.
+ foreach ( $this->checksum_text_fields as $field ) {
+ $checksum_fields[] = 'CONVERT(' . $this->table . '.' . $field . ' using latin1 )';
+ }
+ } else {
+ // Conversion disabled, default to table prefixing.
+ foreach ( $this->checksum_text_fields as $field ) {
+ $checksum_fields[] = $this->table . '.' . $field;
+ }
+ }
+
+ $checksum_fields_string = implode( ',', array_merge( $checksum_fields, array( $salt ) ) );
+
+ $additional_fields = '';
+ if ( $granular_result ) {
+ // TODO uniq the fields as sometimes(most) range_index is the key and there's no need to select the same field twice.
+ $additional_fields = "
+ {$this->table}.{$this->range_field} as range_index,
+ {$key_fields},
+ ";
+ }
+
+ $filter_stamenet = $this->build_filter_statement( $range_from, $range_to, $filter_values );
+
+ $join_statement = '';
+ if ( $this->parent_table ) {
+ $parent_table_obj = new Table_Checksum( $this->parent_table );
+ $parent_filter_query = $parent_table_obj->build_filter_statement( null, null, null, 'parent_table' );
+
+ // It is possible to have the GROUP By cause multiple rows to be returned for the same row for term_taxonomy.
+ // To get distinct entries we use a correlatd subquery back on the parent table using the primary key.
+ $additional_unique_clause = '';
+ if ( 'term_taxonomy' === $this->parent_table ) {
+ $additional_unique_clause = "
+ AND parent_table.{$parent_table_obj->range_field} = (
+ SELECT min( parent_table_cs.{$parent_table_obj->range_field} )
+ FROM {$parent_table_obj->table} as parent_table_cs
+ WHERE parent_table_cs.{$this->parent_join_field} = {$this->table}.{$this->table_join_field}
+ )
+ ";
+ }
+
+ $join_statement = "
+ INNER JOIN {$parent_table_obj->table} as parent_table
+ ON (
+ {$this->table}.{$this->table_join_field} = parent_table.{$this->parent_join_field}
+ AND {$parent_filter_query}
+ $additional_unique_clause
+ )
+ ";
+ }
+
+ $query = "
+ SELECT
+ {$additional_fields}
+ SUM(
+ CRC32(
+ CONCAT_WS( '#', {$salt}, {$checksum_fields_string} )
+ )
+ ) AS checksum
+ FROM
+ {$this->table}
+ {$join_statement}
+ WHERE
+ {$filter_stamenet}
+ ";
+
+ /**
+ * We need the GROUP BY only for compound keys.
+ */
+ if ( $granular_result ) {
+ $query .= "
+ GROUP BY {$key_fields}
+ LIMIT 9999999
+ ";
+ }
+
+ return $query;
+ }
+
+ /**
+ * Obtain the min-max values (edges) of the range.
+ *
+ * @param int|null $range_from The start of the range.
+ * @param int|null $range_to The end of the range.
+ * @param int|null $limit How many values to return.
+ *
+ * @return array|object|void
+ * @throws Exception Throws an exception if validation fails on the internal function calls.
+ */
+ public function get_range_edges( $range_from = null, $range_to = null, $limit = null ) {
+ global $wpdb;
+
+ $this->validate_fields( array( $this->range_field ) );
+
+ // Performance :: When getting the postmeta range we do not want to filter by the whitelist.
+ // The reason for this is that it leads to a non-performant query that can timeout.
+ // Instead lets get the range based on posts regardless of meta.
+ $filter_values = $this->filter_values;
+ if ( 'postmeta' === $this->table ) {
+ $this->filter_values = null;
+ }
+
+ // `trim()` to make sure we don't add the statement if it's empty.
+ $filters = trim( $this->build_filter_statement( $range_from, $range_to ) );
+
+ // Reset Post meta filter.
+ if ( 'postmeta' === $this->table ) {
+ $this->filter_values = $filter_values;
+ }
+
+ $filter_statement = '';
+ if ( ! empty( $filters ) ) {
+ $filter_statement = "
+ WHERE
+ {$filters}
+ ";
+ }
+
+ // Only make the distinct count when we know there can be multiple entries for the range column.
+ $distinct_count = '';
+ if ( count( $this->key_fields ) > 1 || $wpdb->terms === $this->table || $wpdb->term_relationships === $this->table ) {
+ $distinct_count = 'DISTINCT';
+ }
+
+ $query = "
+ SELECT
+ MIN({$this->range_field}) as min_range,
+ MAX({$this->range_field}) as max_range,
+ COUNT( {$distinct_count} {$this->range_field}) as item_count
+ FROM
+ ";
+
+ /**
+ * If `$limit` is not specified, we can directly use the table.
+ */
+ if ( ! $limit ) {
+ $query .= "
+ {$this->table}
+ {$filter_statement}
+ ";
+ } else {
+ /**
+ * If there is `$limit` specified, we can't directly use `MIN/MAX()` as they don't work with `LIMIT`.
+ * That's why we will alter the query for this case.
+ */
+ $limit = intval( $limit );
+
+ $query .= "
+ (
+ SELECT
+ {$distinct_count} {$this->range_field}
+ FROM
+ {$this->table}
+ {$filter_statement}
+ ORDER BY
+ {$this->range_field} ASC
+ LIMIT {$limit}
+ ) as ids_query
+ ";
+ }
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $result = $wpdb->get_row( $query, ARRAY_A );
+
+ if ( ! $result || ! is_array( $result ) ) {
+ throw new Exception( 'Unable to get range edges' );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Update the results to have key/checksum format.
+ *
+ * @param array $results Prepare the results for output of granular results.
+ */
+ protected function prepare_results_for_output( &$results ) {
+ // get the compound key.
+ // only return range and compound key for granular results.
+
+ $return_value = array();
+
+ foreach ( $results as &$result ) {
+ // Working on reference to save memory here.
+
+ $key = array();
+ foreach ( $this->key_fields as $field ) {
+ $key[] = $result[ $field ];
+ }
+
+ $return_value[ implode( '-', $key ) ] = $result['checksum'];
+ }
+
+ return $return_value;
+ }
+
+ /**
+ * Calculate the checksum based on provided range and filters.
+ *
+ * @param int|null $range_from The start of the range.
+ * @param int|null $range_to The end of the range.
+ * @param array|null $filter_values Additional filter values. Not used at the moment.
+ * @param bool $granular_result If the returned result should be granular or only the checksum.
+ * @param bool $simple_return_value If we want to use a simple return value for non-granular results (return only the checksum, without wrappers).
+ *
+ * @return array|mixed|object|WP_Error|null
+ */
+ public function calculate_checksum( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false, $simple_return_value = true ) {
+
+ if ( ! Sync\Settings::is_checksum_enabled() ) {
+ return new WP_Error( 'checksum_disabled', 'Checksums are currently disabled.' );
+ }
+
+ try {
+ $this->validate_input();
+ } catch ( Exception $ex ) {
+ return new WP_Error( 'invalid_input', $ex->getMessage() );
+ }
+
+ $query = $this->build_checksum_query( $range_from, $range_to, $filter_values, $granular_result );
+
+ global $wpdb;
+
+ if ( ! $granular_result ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $result = $wpdb->get_row( $query, ARRAY_A );
+
+ if ( ! is_array( $result ) ) {
+ return new WP_Error( 'invalid_query', "Result wasn't an array" );
+ }
+
+ if ( $simple_return_value ) {
+ return $result['checksum'];
+ }
+
+ return array(
+ 'range' => $range_from . '-' . $range_to,
+ 'checksum' => $result['checksum'],
+ );
+ } else {
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $result = $wpdb->get_results( $query, ARRAY_A );
+ return $this->prepare_results_for_output( $result );
+ }
+ }
+
+ /**
+ * Make sure the WooCommerce tables should be enabled for Checksum/Fix.
+ *
+ * @return bool
+ */
+ protected function enable_woocommerce_tables() {
+ /**
+ * On WordPress.com, we can't directly check if the site has support for WooCommerce.
+ * Having the option to override the functionality here helps with syncing WooCommerce tables.
+ *
+ * @since 10.1
+ *
+ * @param bool If we should we force-enable WooCommerce tables support.
+ */
+ $force_woocommerce_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce', false );
+
+ // If we're forcing WooCommerce tables support, there's no need to check further.
+ // This is used on WordPress.com.
+ if ( $force_woocommerce_support ) {
+ return true;
+ }
+
+ // No need to proceed if WooCommerce is not available.
+ if ( ! class_exists( 'WooCommerce' ) ) {
+ return false;
+ }
+
+ // TODO more checks if needed. Probably query the DB to make sure the tables exist.
+
+ return true;
+ }
+
+}