File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/DataStores.tar
CustomMetaDataStore.php 0000644 00000013662 15154263117 0011162 0 ustar 00 <?php
/**
* CustomMetaDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores;
/**
* Implements functions similar to WP's add_metadata(), get_metadata(), and friends using a custom table.
*
* @see WC_Data_Store_WP For an implementation using WP's metadata functions and tables.
*/
abstract class CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
abstract protected function get_table_name();
/**
* Returns the name of the field/column used for identifiying metadata entries.
*
* @return string
*/
protected function get_meta_id_field() {
return 'id';
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'object_id';
}
/**
* Describes the structure of the metadata table.
*
* @return array Array elements: table, object_id_field, meta_id_field.
*/
protected function get_db_info() {
return array(
'table' => $this->get_table_name(),
'meta_id_field' => $this->get_meta_id_field(),
'object_id_field' => $this->get_object_id_field(),
);
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw_meta_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']} AS meta_id, meta_key, meta_value FROM {$db_info['table']} WHERE {$db_info['object_id_field']} = %d ORDER BY meta_id",
$object->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $raw_meta_data;
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing at least ->id).
*
* @return bool
*/
public function delete_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) ) {
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta->id );
return (bool) $wpdb->delete( $db_info['table'], array( $db_info['meta_id_field'] => $meta_id ) );
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
*
* @return int|false meta ID
*/
public function add_meta( &$object, $meta ) {
global $wpdb;
$db_info = $this->get_db_info();
$object_id = $object->get_id();
$meta_key = wp_unslash( wp_slash( $meta->key ) );
$meta_value = maybe_serialize( is_string( $meta->value ) ? wp_unslash( wp_slash( $meta->value ) ) : $meta->value );
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$result = $wpdb->insert(
$db_info['table'],
array(
$db_info['object_id_field'] => $object_id,
'meta_key' => $meta_key,
'meta_value' => $meta_value,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
return $result ? (int) $wpdb->insert_id : false;
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->id, ->key and ->value).
*
* @return bool
*/
public function update_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) || empty( $meta->key ) ) {
return false;
}
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$data = array(
'meta_key' => $meta->key,
'meta_value' => maybe_serialize( $meta->value ),
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$db_info = $this->get_db_info();
$result = $wpdb->update(
$db_info['table'],
$data,
array( $db_info['meta_id_field'] => $meta->id ),
'%s',
'%d'
);
return 1 === $result;
}
/**
* Retrieves metadata by meta ID.
*
* @param int $meta_id Meta ID.
* @return object|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_id( $meta_id ) {
global $wpdb;
if ( ! is_numeric( $meta_id ) || floor( $meta_id ) != $meta_id ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta_id );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_row(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE {$db_info['meta_id_field']} = %d",
$meta_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
if ( isset( $meta->meta_value ) ) {
$meta->meta_value = maybe_unserialize( $meta->meta_value );
}
return $meta;
}
/**
* Retrieves metadata by meta key.
*
* @param \WC_Abstract_Order $object Object ID.
* @param string $meta_key Meta key.
*
* @return \stdClass|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_key( &$object, string $meta_key ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE meta_key = %s AND {$db_info['object_id_field']} = %d",
$meta_key,
$object->get_id(),
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
foreach ( $meta as $row ) {
if ( isset( $row->meta_value ) ) {
$row->meta_value = maybe_unserialize( $row->meta_value );
}
}
return $meta;
}
}
Orders/CustomOrdersTableController.php 0000644 00000045117 15154263117 0014175 0 ustar 00 <?php
/**
* CustomOrdersTableController class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;
defined( 'ABSPATH' ) || exit;
/**
* This is the main class that controls the custom orders tables feature. Its responsibilities are:
*
* - Displaying UI components (entries in the tools page and in settings)
* - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
*
* ...and in general, any functionality that doesn't imply database access.
*/
class CustomOrdersTableController {
use AccessiblePrivateMethods;
/**
* The name of the option for enabling the usage of the custom orders tables
*/
public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';
/**
* The name of the option that tells whether database transactions are to be used or not for data synchronization.
*/
public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';
/**
* The name of the option to store the transaction isolation level to use when database transactions are enabled.
*/
public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';
public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* Refunds data store object to use.
*
* @var OrdersTableRefundDataStore
*/
private $refund_data_store;
/**
* The data synchronizer object to use.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* The batch processing controller to use.
*
* @var BatchProcessingController
*/
private $batch_processing_controller;
/**
* The features controller to use.
*
* @var FeaturesController
*/
private $features_controller;
/**
* The orders cache object to use.
*
* @var OrderCache
*/
private $order_cache;
/**
* The orders cache controller object to use.
*
* @var OrderCacheController
*/
private $order_cache_controller;
/**
* The plugin util object to use.
*
* @var PluginUtil
*/
private $plugin_util;
/**
* Class constructor.
*/
public function __construct() {
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
self::add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 999, 1 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_data_sync_option_changed' ), 10, 1 );
self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_feature_enabled_changed' ), 10, 2 );
self::add_action( 'woocommerce_feature_setting', array( $this, 'get_hpos_feature_setting' ), 10, 2 );
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param OrdersTableDataStore $data_store The data store to use.
* @param DataSynchronizer $data_synchronizer The data synchronizer to use.
* @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
* @param BatchProcessingController $batch_processing_controller The batch processing controller to use.
* @param FeaturesController $features_controller The features controller instance to use.
* @param OrderCache $order_cache The order cache engine to use.
* @param OrderCacheController $order_cache_controller The order cache controller to use.
* @param PluginUtil $plugin_util The plugin util to use.
*/
final public function init(
OrdersTableDataStore $data_store,
DataSynchronizer $data_synchronizer,
OrdersTableRefundDataStore $refund_data_store,
BatchProcessingController $batch_processing_controller,
FeaturesController $features_controller,
OrderCache $order_cache,
OrderCacheController $order_cache_controller,
PluginUtil $plugin_util
) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
$this->batch_processing_controller = $batch_processing_controller;
$this->refund_data_store = $refund_data_store;
$this->features_controller = $features_controller;
$this->order_cache = $order_cache;
$this->order_cache_controller = $order_cache_controller;
$this->plugin_util = $plugin_util;
}
/**
* Is the custom orders table usage enabled via settings?
* This can be true only if the feature is enabled and a table regeneration has been completed.
*
* @return bool True if the custom orders table usage is enabled
*/
public function custom_orders_table_usage_is_enabled(): bool {
return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
}
/**
* Gets the instance of the orders data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_orders_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order' );
}
/**
* Gets the instance of the refunds data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_refunds_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order_refund' );
}
/**
* Gets the instance of a given data store.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
* @param string $type The type of the data store to get.
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_data_store_instance( $default_data_store, string $type ) {
if ( $this->custom_orders_table_usage_is_enabled() ) {
switch ( $type ) {
case 'order_refund':
return $this->refund_data_store;
default:
return $this->data_store;
}
} else {
return $default_data_store;
}
}
/**
* Add an entry to Status - Tools to create or regenerate the custom orders table,
* and also an entry to delete the table as appropriate.
*
* @param array $tools_array The array of tools to add the tool to.
* @return array The updated array of tools-
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ): array {
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
return $tools_array;
}
if ( $this->custom_orders_table_usage_is_enabled() || $this->data_synchronizer->data_sync_is_enabled() ) {
$disabled = true;
$message = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" is not authoritative and sync is disabled (via Settings > Advanced > Features).', 'woocommerce' );
} else {
$disabled = false;
$message = __( 'This will delete the custom orders tables. To create them again enable the "High-Performance order storage" feature (via Settings > Advanced > Features).', 'woocommerce' );
}
$tools_array['delete_custom_orders_table'] = array(
'name' => __( 'Delete the custom orders tables', 'woocommerce' ),
'desc' => sprintf(
'<strong class="red">%1$s</strong> %2$s',
__( 'Note:', 'woocommerce' ),
$message
),
'requires_refresh' => true,
'callback' => function () {
$this->features_controller->change_feature_enable( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, false );
$this->delete_custom_orders_tables();
return __( 'Custom orders tables have been deleted.', 'woocommerce' );
},
'button' => __( 'Delete', 'woocommerce' ),
'disabled' => $disabled,
);
return $tools_array;
}
/**
* Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
*
* @throws \Exception Can't delete the tables.
*/
private function delete_custom_orders_tables() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Features)." );
}
delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
$this->data_synchronizer->delete_database_tables();
}
/**
* Handler for the individual setting updated hook.
*
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
* @param mixed $value New value of the setting.
*/
private function process_updated_option( $option, $old_value, $value ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
$this->data_synchronizer->cleanup_synchronization_state();
}
}
/**
* Handler for the setting pre-update hook.
* We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
*
* @param mixed $value New value of the setting.
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
*
* @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
*/
private function process_pre_update_option( $value, $option, $old_value ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && $value !== $old_value ) {
$this->order_cache->flush();
return $value;
}
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) {
return $value;
}
$this->order_cache->flush();
/**
* Re-enable the following code once the COT to posts table sync is implemented (it's currently commented out to ease testing).
$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
if ( $sync_is_pending ) {
throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
}
*/
return $value;
}
/**
* Handler for the all settings updated hook.
*
* @param string $feature_id Feature ID.
*/
private function handle_data_sync_option_changed( string $feature_id ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION !== $feature_id ) {
return;
}
$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$this->data_synchronizer->create_database_tables();
}
// Enabling/disabling the sync implies starting/stopping it too, if needed.
// We do this check here, and not in process_pre_update_option, so that if for some reason
// the setting is enabled but no sync is in process, sync will start by just saving the
// settings even without modifying them (and the opposite: sync will be stopped if for
// some reason it was ongoing while it was disabled).
if ( $data_sync_is_enabled ) {
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
} else {
$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
}
}
/**
* Handle the 'woocommerce_feature_enabled_changed' action,
* if the custom orders table feature is enabled create the database tables if they don't exist.
*
* @param string $feature_id The id of the feature that is being enabled or disabled.
* @param bool $is_enabled True if the feature is being enabled, false if it's being disabled.
*/
private function handle_feature_enabled_changed( $feature_id, $is_enabled ): void {
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $feature_id || ! $is_enabled ) {
return;
}
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$success = $this->data_synchronizer->create_database_tables();
if ( ! $success ) {
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
}
}
}
/**
* Handler for the woocommerce_after_register_post_type post,
* registers the post type for placeholder orders.
*
* @return void
*/
private function register_post_type_for_order_placeholders(): void {
wc_register_order_type(
DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
array(
'public' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'show_in_admin_bar' => false,
'show_in_rest' => false,
'rewrite' => false,
'query_var' => false,
'can_export' => false,
'supports' => array(),
'capabilities' => array(),
'exclude_from_order_count' => true,
'exclude_from_order_views' => true,
'exclude_from_order_reports' => true,
'exclude_from_order_sales_reports' => true,
)
);
}
/**
* Returns the HPOS setting for rendering in Features section of the settings page.
*
* @param array $feature_setting HPOS feature value as defined in the feature controller.
* @param string $feature_id ID of the feature.
*
* @return array Feature setting object.
*/
private function get_hpos_feature_setting( array $feature_setting, string $feature_id ) {
if ( ! in_array( $feature_id, array( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'custom_order_tables' ), true ) ) {
return $feature_setting;
}
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return $feature_setting;
}
$sync_status = $this->data_synchronizer->get_sync_status();
switch ( $feature_id ) {
case self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
return $this->get_hpos_setting_for_feature( $sync_status );
case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
return $this->get_hpos_setting_for_sync( $sync_status );
case 'custom_order_tables':
return array();
}
}
/**
* Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_feature( $sync_status ) {
$hpos_enabled = $this->custom_orders_table_usage_is_enabled();
$plugin_info = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
$plugin_incompat_warning = $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_info );
$sync_complete = 0 === $sync_status['current_pending_count'];
$disabled_option = array();
// Changing something here? might also want to look at `enable|disable` functions in CLIRunner.
if ( count( array_merge( $plugin_info['uncertain'], $plugin_info['incompatible'] ) ) > 0 ) {
$disabled_option = array( 'yes' );
}
if ( ! $sync_complete ) {
$disabled_option = array( 'yes', 'no' );
}
return array(
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
'title' => __( 'Order data storage', 'woocommerce' ),
'type' => 'radio',
'options' => array(
'no' => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
),
'value' => $hpos_enabled ? 'yes' : 'no',
'disabled' => $disabled_option,
'desc' => $plugin_incompat_warning,
'desc_at_end' => true,
'row_class' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
);
}
/**
* Returns the setting for rendering sync enabling setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_sync( $sync_status ) {
$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
$sync_enabled = get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
$sync_message = '';
if ( $sync_in_progress && $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
__( 'Currently syncing orders... %d pending', 'woocommerce' ),
$sync_status['current_pending_count']
);
} elseif ( $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
_n(
'%d order pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
'%d orders pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
$sync_status['current_pending_count'],
'woocommerce'
),
$sync_status['current_pending_count'],
);
}
return array(
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
'title' => '',
'type' => 'checkbox',
'desc' => __( 'Enable compatibility mode (synchronizes orders to the posts table).', 'woocommerce' ),
'value' => $sync_enabled,
'desc_tip' => $sync_message,
'row_class' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
);
}
}
Orders/DataSynchronizer.php 0000644 00000067331 15154263117 0012021 0 ustar 00 <?php
/**
* DataSynchronizer class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilites are:
*
* - Providing entry points for creating and deleting the required database tables.
* - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen.
*/
class DataSynchronizer implements BatchProcessorInterface {
use AccessiblePrivateMethods;
public const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled';
private const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count';
public const PLACEHOLDER_ORDER_POST_TYPE = 'shop_order_placehold';
public const DELETED_RECORD_META_KEY = '_deleted_from';
public const DELETED_FROM_POSTS_META_VALUE = 'posts_table';
public const DELETED_FROM_ORDERS_META_VALUE = 'orders_table';
public const ORDERS_TABLE_CREATED = 'woocommerce_custom_orders_table_created';
private const ORDERS_SYNC_BATCH_SIZE = 250;
// Allowed values for $type in get_ids_of_orders_pending_sync method.
public const ID_TYPE_MISSING_IN_ORDERS_TABLE = 0;
public const ID_TYPE_MISSING_IN_POSTS_TABLE = 1;
public const ID_TYPE_DIFFERENT_UPDATE_DATE = 2;
public const ID_TYPE_DELETED_FROM_ORDERS_TABLE = 3;
public const ID_TYPE_DELETED_FROM_POSTS_TABLE = 4;
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
private $database_util;
/**
* The posts to COT migrator to use.
*
* @var PostsToOrdersMigrationController
*/
private $posts_to_cot_migrator;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* The order cache controller.
*
* @var OrderCacheController
*/
private $order_cache_controller;
/**
* Class constructor.
*/
public function __construct() {
self::add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
self::add_action( 'woocommerce_new_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );
self::add_filter( 'woocommerce_feature_description_tip', array( $this, 'handle_feature_description_tip' ), 10, 3 );
}
/**
* Class initialization, invoked by the DI container.
*
* @param OrdersTableDataStore $data_store The data store to use.
* @param DatabaseUtil $database_util The database util class to use.
* @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
* @param OrderCacheController $order_cache_controller The order cache controller instance to use.
* @internal
*/
final public function init(
OrdersTableDataStore $data_store,
DatabaseUtil $database_util,
PostsToOrdersMigrationController $posts_to_cot_migrator,
LegacyProxy $legacy_proxy,
OrderCacheController $order_cache_controller
) {
$this->data_store = $data_store;
$this->database_util = $database_util;
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->order_cache_controller = $order_cache_controller;
}
/**
* Does the custom orders tables exist in the database?
*
* @return bool True if the custom orders tables exist in the database.
*/
public function check_orders_table_exists(): bool {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
if ( count( $missing_tables ) === 0 ) {
update_option( self::ORDERS_TABLE_CREATED, 'yes' );
return true;
} else {
update_option( self::ORDERS_TABLE_CREATED, 'no' );
return false;
}
}
/**
* Returns the value of the orders table created option. If it's not set, then it checks the orders table and set it accordingly.
*
* @return bool Whether orders table exists.
*/
public function get_table_exists(): bool {
$table_exists = get_option( self::ORDERS_TABLE_CREATED );
switch ( $table_exists ) {
case 'no':
case 'yes':
return 'yes' === $table_exists;
default:
return $this->check_orders_table_exists();
}
}
/**
* Create the custom orders database tables.
*/
public function create_database_tables() {
$this->database_util->dbdelta( $this->data_store->get_database_schema() );
$success = $this->check_orders_table_exists();
if ( ! $success ) {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
$missing_tables = implode( ', ', $missing_tables );
$this->error_logger->error( "HPOS tables are missing in the database and couldn't be created. The missing tables are: $missing_tables" );
}
return $success;
}
/**
* Delete the custom orders database tables.
*/
public function delete_database_tables() {
$table_names = $this->data_store->get_all_table_names();
foreach ( $table_names as $table_name ) {
$this->database_util->drop_database_table( $table_name );
}
delete_option( self::ORDERS_TABLE_CREATED );
}
/**
* Is the data sync between old and new tables currently enabled?
*
* @return bool
*/
public function data_sync_is_enabled(): bool {
return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
}
/**
* Get the current sync process status.
* The information is meaningful only if pending_data_sync_is_in_progress return true.
*
* @return array
*/
public function get_sync_status() {
return array(
'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ),
'current_pending_count' => $this->get_total_pending_count(),
);
}
/**
* Get the total number of orders pending synchronization.
*
* @return int
*/
public function get_current_orders_pending_sync_count_cached() : int {
return $this->get_current_orders_pending_sync_count( true );
}
/**
* Calculate how many orders need to be synchronized currently.
* A database query is performed to get how many orders match one of the following:
*
* - Existing in the authoritative table but not in the backup table.
* - Existing in both tables, but they have a different update date.
*
* @param bool $use_cache Whether to use the cached value instead of fetching from database.
*/
public function get_current_orders_pending_sync_count( $use_cache = false ): int {
global $wpdb;
if ( $use_cache ) {
$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count' );
if ( false !== $pending_count ) {
return (int) $pending_count;
}
}
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
$orders_table = $this->data_store::get_orders_table_name();
if ( empty( $order_post_types ) ) {
$this->error_logger->debug(
sprintf(
/* translators: 1: method name. */
esc_html__( '%1$s was called but no order types were registered: it may have been called too early.', 'woocommerce' ),
__METHOD__
)
);
return 0;
}
if ( ! $this->get_table_exists() ) {
$count = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts where post_type in ( $order_post_type_placeholder )",
$order_post_types
)
// phpcs:enable
);
return $count;
}
if ( $this->custom_orders_table_is_authoritative() ) {
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholder)",
$order_post_types
);
$operator = '>';
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.id=orders.id
WHERE
posts.post_type in ($order_post_type_placeholder)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL",
$order_post_types
);
// phpcs:enable
$operator = '<';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $missing_orders_count_sql is prepared.
$sql = $wpdb->prepare(
"
SELECT(
($missing_orders_count_sql)
+
(SELECT COUNT(1) FROM (
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholder)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
) x)
) count",
$order_post_types
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$pending_count = (int) $wpdb->get_var( $sql );
$deleted_from_table = $this->get_current_deletion_record_meta_value();
$deleted_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT count(1) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s",
array( self::DELETED_RECORD_META_KEY, $deleted_from_table )
)
);
$pending_count += $deleted_count;
wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count );
return $pending_count;
}
/**
* Get the meta value for order deletion records based on which table is currently authoritative.
*
* @return string self::DELETED_FROM_ORDERS_META_VALUE if the orders table is authoritative, self::DELETED_FROM_POSTS_META_VALUE otherwise.
*/
private function get_current_deletion_record_meta_value() {
return $this->custom_orders_table_is_authoritative() ?
self::DELETED_FROM_ORDERS_META_VALUE :
self::DELETED_FROM_POSTS_META_VALUE;
}
/**
* Is the custom orders table the authoritative data source for orders currently?
*
* @return bool Whether the custom orders table the authoritative data source for orders currently.
*/
public function custom_orders_table_is_authoritative(): bool {
return wc_string_to_bool( get_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) );
}
/**
* Get a list of ids of orders than are out of sync.
*
* Valid values for $type are:
*
* ID_TYPE_MISSING_IN_ORDERS_TABLE: orders that exist in posts table but not in orders table.
* ID_TYPE_MISSING_IN_POSTS_TABLE: orders that exist in orders table but not in posts table (the corresponding post entries are placeholders).
* ID_TYPE_DIFFERENT_UPDATE_DATE: orders that exist in both tables but have different last update dates.
* ID_TYPE_DELETED_FROM_ORDERS_TABLE: orders deleted from the orders table but not yet from the posts table.
* ID_TYPE_DELETED_FROM_POSTS_TABLE: orders deleted from the posts table but not yet from the orders table.
*
* @param int $type One of ID_TYPE_MISSING_IN_ORDERS_TABLE, ID_TYPE_MISSING_IN_POSTS_TABLE, ID_TYPE_DIFFERENT_UPDATE_DATE.
* @param int $limit Maximum number of ids to return.
* @return array An array of order ids.
* @throws \Exception Invalid parameter.
*/
public function get_ids_of_orders_pending_sync( int $type, int $limit ) {
global $wpdb;
if ( $limit < 1 ) {
throw new \Exception( '$limit must be at least 1' );
}
$orders_table = $this->data_store::get_orders_table_name();
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
switch ( $type ) {
case self::ID_TYPE_MISSING_IN_ORDERS_TABLE:
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL
ORDER BY posts.ID ASC",
$order_post_types
);
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
break;
case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.id ASC",
$order_post_types
);
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
ORDER BY orders.id ASC
",
$order_post_types
);
// phpcs:enable
break;
case self::ID_TYPE_DELETED_FROM_ORDERS_TABLE:
return $this->get_deleted_order_ids( true, $limit );
case self::ID_TYPE_DELETED_FROM_POSTS_TABLE:
return $this->get_deleted_order_ids( false, $limit );
default:
throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' );
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB
return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) );
}
/**
* Get the ids of the orders that are marked as deleted in the orders meta table.
*
* @param bool $deleted_from_orders_table True to get the ids of the orders deleted from the orders table, false o get the ids of the orders deleted from the posts table.
* @param int $limit The maximum count of orders to return.
* @return array An array of order ids.
*/
private function get_deleted_order_ids( bool $deleted_from_orders_table, int $limit ) {
global $wpdb;
$deleted_from_table = $this->get_current_deletion_record_meta_value();
$order_ids = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT DISTINCT(order_id) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s LIMIT {$limit}",
self::DELETED_RECORD_META_KEY,
$deleted_from_table
)
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
return array_map( 'absint', $order_ids );
}
/**
* Cleanup all the synchronization status information,
* because the process has been disabled by the user via settings,
* or because there's nothing left to synchronize.
*/
public function cleanup_synchronization_state() {
delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION );
}
/**
* Process data for current batch.
*
* @param array $batch Batch details.
*/
public function process_batch( array $batch ) : void {
if ( empty( $batch ) ) {
return;
}
$batch = array_map( 'absint', $batch );
$this->order_cache_controller->temporarily_disable_orders_cache_usage();
$custom_orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
$deleted_order_ids = $this->process_deleted_orders( $batch, $custom_orders_table_is_authoritative );
$batch = array_diff( $batch, $deleted_order_ids );
if ( ! empty( $batch ) ) {
if ( $custom_orders_table_is_authoritative ) {
foreach ( $batch as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
$this->error_logger->error( "Order $id not found during batch process, skipping." );
continue;
}
$data_store = $order->get_data_store();
$data_store->backfill_post_record( $order );
}
} else {
$this->posts_to_cot_migrator->migrate_orders( $batch );
}
}
if ( 0 === $this->get_total_pending_count() ) {
$this->cleanup_synchronization_state();
$this->order_cache_controller->maybe_restore_orders_cache_usage();
}
}
/**
* Take a batch of order ids pending synchronization and process those that were deleted, ignoring the others
* (which will be orders that were created or modified) and returning the ids of the orders actually processed.
*
* @param array $batch Array of ids of order pending synchronization.
* @param bool $custom_orders_table_is_authoritative True if the custom orders table is currently authoritative.
* @return array Order ids that have been actually processed.
*/
private function process_deleted_orders( array $batch, bool $custom_orders_table_is_authoritative ): array {
global $wpdb;
$deleted_from_table_name = $this->get_current_deletion_record_meta_value();
$data_store_for_deletion =
$custom_orders_table_is_authoritative ?
new \WC_Order_Data_Store_CPT() :
wc_get_container()->get( OrdersTableDataStore::class );
$order_ids_as_sql_list = '(' . implode( ',', $batch ) . ')';
$deleted_order_ids = array();
$meta_ids_to_delete = array();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$deletion_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, order_id FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s AND order_id IN $order_ids_as_sql_list ORDER BY order_id DESC",
self::DELETED_RECORD_META_KEY,
$deleted_from_table_name
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $deletion_data ) ) {
return array();
}
foreach ( $deletion_data as $item ) {
$meta_id = $item['id'];
$order_id = $item['order_id'];
if ( isset( $deleted_order_ids[ $order_id ] ) ) {
$meta_ids_to_delete[] = $meta_id;
continue;
}
if ( ! $data_store_for_deletion->order_exists( $order_id ) ) {
$this->error_logger->warning( "Order {$order_id} doesn't exist in the backup table, thus it can't be deleted" );
$deleted_order_ids[] = $order_id;
$meta_ids_to_delete[] = $meta_id;
continue;
}
try {
$order = new \WC_Order();
$order->set_id( $order_id );
$data_store_for_deletion->read( $order );
$data_store_for_deletion->delete(
$order,
array(
'force_delete' => true,
'suppress_filters' => true,
)
);
} catch ( \Exception $ex ) {
$this->error_logger->error( "Couldn't delete order {$order_id} from the backup table: {$ex->getMessage()}" );
continue;
}
$deleted_order_ids[] = $order_id;
$meta_ids_to_delete[] = $meta_id;
}
if ( ! empty( $meta_ids_to_delete ) ) {
$order_id_rows_as_sql_list = '(' . implode( ',', $meta_ids_to_delete ) . ')';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_orders_meta WHERE id IN {$order_id_rows_as_sql_list}" );
}
return $deleted_order_ids;
}
/**
* Get total number of pending records that require update.
*
* @return int Number of pending records.
*/
public function get_total_pending_count(): int {
return $this->get_current_orders_pending_sync_count();
}
/**
* Returns the batch with records that needs to be processed for a given size.
*
* @param int $size Size of the batch.
*
* @return array Batch of records.
*/
public function get_next_batch_to_process( int $size ): array {
$orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
$order_ids = $this->get_ids_of_orders_pending_sync(
$orders_table_is_authoritative ? self::ID_TYPE_MISSING_IN_POSTS_TABLE : self::ID_TYPE_MISSING_IN_ORDERS_TABLE,
$size
);
if ( count( $order_ids ) >= $size ) {
return $order_ids;
}
$updated_order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) );
$order_ids = array_merge( $order_ids, $updated_order_ids );
if ( count( $order_ids ) >= $size ) {
return $order_ids;
}
$deleted_order_ids = $this->get_ids_of_orders_pending_sync(
$orders_table_is_authoritative ? self::ID_TYPE_DELETED_FROM_ORDERS_TABLE : self::ID_TYPE_DELETED_FROM_POSTS_TABLE,
$size - count( $order_ids )
);
$order_ids = array_merge( $order_ids, $deleted_order_ids );
return array_map( 'absint', $order_ids );
}
/**
* Default batch size to use.
*
* @return int Default batch size.
*/
public function get_default_batch_size(): int {
$batch_size = self::ORDERS_SYNC_BATCH_SIZE;
if ( $this->custom_orders_table_is_authoritative() ) {
// Back-filling is slower than migration.
$batch_size = absint( self::ORDERS_SYNC_BATCH_SIZE / 10 ) + 1;
}
/**
* Filter to customize the count of orders that will be synchronized in each step of the custom orders table to/from posts table synchronization process.
*
* @since 6.6.0
*
* @param int Default value for the count.
*/
return apply_filters( 'woocommerce_orders_cot_and_posts_sync_step_size', $batch_size );
}
/**
* A user friendly name for this process.
*
* @return string Name of the process.
*/
public function get_name(): string {
return 'Order synchronizer';
}
/**
* A user friendly description for this process.
*
* @return string Description.
*/
public function get_description(): string {
return 'Synchronizes orders between posts and custom order tables.';
}
/**
* Handle the 'deleted_post' action.
*
* When posts is authoritative and sync is enabled, deleting a post also deletes COT data.
*
* @param int $postid The post id.
* @param WP_Post $post The deleted post.
*/
private function handle_deleted_post( $postid, $post ): void {
global $wpdb;
$order_post_types = wc_get_order_types( 'cot-migration' );
if ( ! in_array( $post->post_type, $order_post_types, true ) ) {
return;
}
if ( ! $this->get_table_exists() ) {
return;
}
if ( $this->data_sync_is_enabled() ) {
$this->data_store->delete_order_data_from_custom_order_tables( $postid );
} elseif ( $this->custom_orders_table_is_authoritative() ) {
return;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
if ( $wpdb->get_var(
$wpdb->prepare(
"SELECT EXISTS (SELECT id FROM {$this->data_store::get_orders_table_name()} WHERE ID=%d)
AND NOT EXISTS (SELECT order_id FROM {$this->data_store::get_meta_table_name()} WHERE order_id=%d AND meta_key=%s AND meta_value=%s)",
$postid,
$postid,
self::DELETED_RECORD_META_KEY,
self::DELETED_FROM_POSTS_META_VALUE
)
)
) {
$wpdb->insert(
$this->data_store::get_meta_table_name(),
array(
'order_id' => $postid,
'meta_key' => self::DELETED_RECORD_META_KEY,
'meta_value' => self::DELETED_FROM_POSTS_META_VALUE,
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
}
/**
* Handle the 'woocommerce_update_order' action.
*
* When posts is authoritative and sync is enabled, updating a post triggers a corresponding change in the COT table.
*
* @param int $order_id The order id.
*/
private function handle_updated_order( $order_id ): void {
if ( ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
$this->posts_to_cot_migrator->migrate_orders( array( $order_id ) );
}
}
/**
* Handles deletion of auto-draft orders in sync with WP's own auto-draft deletion.
*
* @since 7.7.0
*
* @return void
*/
private function delete_auto_draft_orders() {
if ( ! $this->custom_orders_table_is_authoritative() ) {
return;
}
// Fetch auto-draft orders older than 1 week.
$to_delete = wc_get_orders(
array(
'date_query' => array(
array(
'column' => 'date_created',
'before' => '-1 week',
),
),
'orderby' => 'date',
'order' => 'ASC',
'status' => 'auto-draft',
)
);
foreach ( $to_delete as $order ) {
$order->delete( true );
}
/**
* Fires after schedueld deletion of auto-draft orders has been completed.
*
* @since 7.7.0
*/
do_action( 'woocommerce_scheduled_auto_draft_delete' );
}
/**
* Handle the 'woocommerce_feature_description_tip' filter.
*
* When the COT feature is enabled and there are orders pending sync (in either direction),
* show a "you should ync before disabling" warning under the feature in the features page.
* Skip this if the UI prevents changing the feature enable status.
*
* @param string $desc_tip The original description tip for the feature.
* @param string $feature_id The feature id.
* @param bool $ui_disabled True if the UI doesn't allow to enable or disable the feature.
* @return string The new description tip for the feature.
*/
private function handle_feature_description_tip( $desc_tip, $feature_id, $ui_disabled ): string {
if ( 'custom_order_tables' !== $feature_id || $ui_disabled ) {
return $desc_tip;
}
$features_controller = wc_get_container()->get( FeaturesController::class );
$feature_is_enabled = $features_controller->feature_is_enabled( 'custom_order_tables' );
if ( ! $feature_is_enabled ) {
return $desc_tip;
}
$pending_sync_count = $this->get_current_orders_pending_sync_count();
if ( ! $pending_sync_count ) {
return $desc_tip;
}
if ( $this->custom_orders_table_is_authoritative() ) {
$extra_tip = sprintf(
_n(
"âš There's one order pending sync from the orders table to the posts table. The feature shouldn't be disabled until this order is synchronized.",
"âš There are %1\$d orders pending sync from the orders table to the posts table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
} else {
$extra_tip = sprintf(
_n(
"âš There's one order pending sync from the posts table to the orders table. The feature shouldn't be disabled until this order is synchronized.",
"âš There are %1\$d orders pending sync from the posts table to the orders table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
}
$cot_settings_url = add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'advanced',
'section' => 'custom_data_stores',
),
admin_url( 'admin.php' )
);
/* translators: %s = URL of the custom data stores settings page */
$manage_cot_settings_link = sprintf( __( "<a href='%s'>Manage orders synchronization</a>", 'woocommerce' ), $cot_settings_url );
return $desc_tip ? "{$desc_tip}<br/>{$extra_tip} {$manage_cot_settings_link}" : "{$extra_tip} {$manage_cot_settings_link}";
}
}
Orders/OrdersTableDataStore.php 0000644 00000273414 15154263117 0012550 0 ustar 00 <?php
/**
* OrdersTableDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Exception;
use WC_Abstract_Order;
use WC_Data;
use WC_Order;
defined( 'ABSPATH' ) || exit;
/**
* This class is the standard data store to be used when the custom orders table is in use.
*/
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {
/**
* Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
*
* @var array.
*/
private static $reading_order_ids = array();
/**
* Keep track of order IDs that are actively being backfilled. We use this to prevent further read on sync from add_|update_|delete_postmeta etc hooks. If we allow this, then we would end up syncing the same order multiple times as it is being backfilled.
*
* @var array
*/
private static $backfilling_order_ids = array();
/**
* Data stored in meta keys, but not considered "meta" for an order.
*
* @since 7.0.0
* @var array
*/
protected $internal_meta_keys = array(
'_customer_user',
'_order_key',
'_order_currency',
'_billing_first_name',
'_billing_last_name',
'_billing_company',
'_billing_address_1',
'_billing_address_2',
'_billing_city',
'_billing_state',
'_billing_postcode',
'_billing_country',
'_billing_email',
'_billing_phone',
'_shipping_first_name',
'_shipping_last_name',
'_shipping_company',
'_shipping_address_1',
'_shipping_address_2',
'_shipping_city',
'_shipping_state',
'_shipping_postcode',
'_shipping_country',
'_shipping_phone',
'_completed_date',
'_paid_date',
'_edit_last',
'_cart_discount',
'_cart_discount_tax',
'_order_shipping',
'_order_shipping_tax',
'_order_tax',
'_order_total',
'_payment_method',
'_payment_method_title',
'_transaction_id',
'_customer_ip_address',
'_customer_user_agent',
'_created_via',
'_order_version',
'_prices_include_tax',
'_date_completed',
'_date_paid',
'_payment_tokens',
'_billing_address_index',
'_shipping_address_index',
'_recorded_sales',
'_recorded_coupon_usage_counts',
'_download_permissions_granted',
'_order_stock_reduced',
'_new_order_email_sent',
);
/**
* Handles custom metadata in the wc_orders_meta table.
*
* @var OrdersTableDataStoreMeta
*/
protected $data_store_meta;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
protected $database_util;
/**
* The posts data store object to use.
*
* @var \WC_Order_Data_Store_CPT
*/
private $cpt_data_store;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* The name of the main orders table.
*
* @var string
*/
private $orders_table_name;
/**
* The instance of the LegacyProxy object to use.
*
* @var LegacyProxy
*/
private $legacy_proxy;
/**
* Initialize the object.
*
* @internal
* @param OrdersTableDataStoreMeta $data_store_meta Metadata instance.
* @param DatabaseUtil $database_util The database util instance to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
*
* @return void
*/
final public function init( OrdersTableDataStoreMeta $data_store_meta, DatabaseUtil $database_util, LegacyProxy $legacy_proxy ) {
$this->data_store_meta = $data_store_meta;
$this->database_util = $database_util;
$this->legacy_proxy = $legacy_proxy;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->internal_meta_keys = $this->get_internal_meta_keys();
$this->orders_table_name = self::get_orders_table_name();
}
/**
* Get the custom orders table name.
*
* @return string The custom orders table name.
*/
public static function get_orders_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders';
}
/**
* Get the order addresses table name.
*
* @return string The order addresses table name.
*/
public static function get_addresses_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_addresses';
}
/**
* Get the orders operational data table name.
*
* @return string The orders operational data table name.
*/
public static function get_operational_data_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_operational_data';
}
/**
* Get the orders meta data table name.
*
* @return string Name of order meta data table.
*/
public static function get_meta_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders_meta';
}
/**
* Get the names of all the tables involved in the custom orders table feature.
*
* See also : get_all_table_names_with_id.
*
* @return string[]
*/
public function get_all_table_names() {
return array(
$this->get_orders_table_name(),
$this->get_addresses_table_name(),
$this->get_operational_data_table_name(),
$this->get_meta_table_name(),
);
}
/**
* Similar to get_all_table_names, but also returns the table name along with the items table.
*
* @return array Names of the tables.
*/
public static function get_all_table_names_with_id() {
global $wpdb;
return array(
'orders' => self::get_orders_table_name(),
'addresses' => self::get_addresses_table_name(),
'operational_data' => self::get_operational_data_table_name(),
'meta' => self::get_meta_table_name(),
'items' => $wpdb->prefix . 'woocommerce_order_items',
);
}
/**
* Table column to WC_Order mapping for wc_orders table.
*
* @var \string[][]
*/
protected $order_column_mapping = array(
'id' => array(
'type' => 'int',
'name' => 'id',
),
'status' => array(
'type' => 'string',
'name' => 'status',
),
'type' => array(
'type' => 'string',
'name' => 'type',
),
'currency' => array(
'type' => 'string',
'name' => 'currency',
),
'tax_amount' => array(
'type' => 'decimal',
'name' => 'cart_tax',
),
'total_amount' => array(
'type' => 'decimal',
'name' => 'total',
),
'customer_id' => array(
'type' => 'int',
'name' => 'customer_id',
),
'billing_email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'date_created_gmt' => array(
'type' => 'date',
'name' => 'date_created',
),
'date_updated_gmt' => array(
'type' => 'date',
'name' => 'date_modified',
),
'parent_order_id' => array(
'type' => 'int',
'name' => 'parent_id',
),
'payment_method' => array(
'type' => 'string',
'name' => 'payment_method',
),
'payment_method_title' => array(
'type' => 'string',
'name' => 'payment_method_title',
),
'ip_address' => array(
'type' => 'string',
'name' => 'customer_ip_address',
),
'transaction_id' => array(
'type' => 'string',
'name' => 'transaction_id',
),
'user_agent' => array(
'type' => 'string',
'name' => 'customer_user_agent',
),
'customer_note' => array(
'type' => 'string',
'name' => 'customer_note',
),
);
/**
* Table column to WC_Order mapping for billing addresses in wc_address table.
*
* @var \string[][]
*/
protected $billing_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'billing_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'billing_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'billing_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'billing_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'billing_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'billing_city',
),
'state' => array(
'type' => 'string',
'name' => 'billing_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'billing_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'billing_country',
),
'email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'phone' => array(
'type' => 'string',
'name' => 'billing_phone',
),
);
/**
* Table column to WC_Order mapping for shipping addresses in wc_address table.
*
* @var \string[][]
*/
protected $shipping_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'shipping_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'shipping_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'shipping_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'shipping_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'shipping_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'shipping_city',
),
'state' => array(
'type' => 'string',
'name' => 'shipping_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'shipping_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'shipping_country',
),
'email' => array( 'type' => 'string' ),
'phone' => array(
'type' => 'string',
'name' => 'shipping_phone',
),
);
/**
* Table column to WC_Order mapping for wc_operational_data table.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'created_via' => array(
'type' => 'string',
'name' => 'created_via',
),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'download_permission_granted' => array(
'type' => 'bool',
'name' => 'download_permissions_granted',
),
'cart_hash' => array(
'type' => 'string',
'name' => 'cart_hash',
),
'new_order_email_sent' => array(
'type' => 'bool',
'name' => 'new_order_email_sent',
),
'order_key' => array(
'type' => 'string',
'name' => 'order_key',
),
'order_stock_reduced' => array(
'type' => 'bool',
'name' => 'order_stock_reduced',
),
'date_paid_gmt' => array(
'type' => 'date',
'name' => 'date_paid',
),
'date_completed_gmt' => array(
'type' => 'date',
'name' => 'date_completed',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
'recorded_sales' => array(
'type' => 'bool',
'name' => 'recorded_sales',
),
);
/**
* Cache variable to store combined mapping.
*
* @var array[][][]
*/
private $all_order_column_mapping;
/**
* Return combined mappings for all order tables.
*
* @return array|\array[][][] Return combined mapping.
*/
public function get_all_order_column_mappings() {
if ( ! isset( $this->all_order_column_mapping ) ) {
$this->all_order_column_mapping = array(
'orders' => $this->order_column_mapping,
'billing_address' => $this->billing_address_column_mapping,
'shipping_address' => $this->shipping_address_column_mapping,
'operational_data' => $this->operational_data_column_mapping,
);
}
return $this->all_order_column_mapping;
}
/**
* Helper function to get alias for order table, this is used in select query.
*
* @return string Alias.
*/
private function get_order_table_alias() : string {
return 'o';
}
/**
* Helper function to get alias for op table, this is used in select query.
*
* @return string Alias.
*/
private function get_op_table_alias() : string {
return 'p';
}
/**
* Helper function to get alias for address table, this is used in select query.
*
* @param string $type Type of address; 'billing' or 'shipping'.
*
* @return string Alias.
*/
private function get_address_table_alias( string $type ) : string {
return 'billing' === $type ? 'b' : 's';
}
/**
* Helper method to get a CPT data store instance to use.
*
* @return \WC_Order_Data_Store_CPT Data store instance.
*/
public function get_cpt_data_store_instance() {
if ( ! isset( $this->cpt_data_store ) ) {
$this->cpt_data_store = $this->get_post_data_store_for_backfill();
}
return $this->cpt_data_store;
}
/**
* Returns data store object to use backfilling.
*
* @return \Abstract_WC_Order_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Data_Store_CPT();
}
/**
* Backfills order details in to WP_Post DB. Uses WC_Order_Data_store_CPT.
*
* @param \WC_Abstract_Order $order Order object to backfill.
*/
public function backfill_post_record( $order ) {
$cpt_data_store = $this->get_post_data_store_for_backfill();
if ( is_null( $cpt_data_store ) || ! method_exists( $cpt_data_store, 'update_order_from_object' ) ) {
return;
}
self::$backfilling_order_ids[] = $order->get_id();
$this->update_order_meta_from_object( $order );
$order_class = get_class( $order );
$post_order = new $order_class();
$post_order->set_id( $order->get_id() );
$cpt_data_store->read( $post_order );
// This compares the order data to the post data and set changes array for props that are changed.
$post_order->set_props( $order->get_data() );
$cpt_data_store->update_order_from_object( $post_order );
foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
if (
is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
is_callable( array( $this, "get_$getter_name" ) )
) {
call_user_func_array(
array(
$cpt_data_store,
"set_$getter_name",
),
array(
$order,
$this->{"get_$getter_name"}( $order ),
)
);
}
}
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
/**
* Get information about whether permissions are granted yet.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether permissions are granted.
*/
public function get_download_permissions_granted( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_download_permissions_granted();
}
/**
* Stores information about whether permissions were generated yet.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_download_permissions_granted( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_download_permissions_granted( $set );
$order->save();
}
/**
* Gets information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether sales are recorded.
*/
public function get_recorded_sales( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_recorded_sales();
}
/**
* Stores information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_sales( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_sales( $set );
$order->save();
}
/**
* Gets information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether coupon counts were updated.
*/
public function get_recorded_coupon_usage_counts( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_recorded_coupon_usage_counts();
}
/**
* Stores information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_coupon_usage_counts( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_coupon_usage_counts( $set );
$order->save();
}
/**
* Whether email have been sent for this order.
*
* @param \WC_Order|int $order Order object.
*
* @return bool Whether email is sent.
*/
public function get_email_sent( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_new_order_email_sent();
}
/**
* Stores information about whether email was sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Helper setter for email_sent.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether email was sent.
*/
public function get_new_order_email_sent( $order ) {
return $this->get_email_sent( $order );
}
/**
* Helper setter for new order email sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_new_order_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Gets information about whether stock was reduced.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether stock was reduced.
*/
public function get_stock_reduced( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_order_stock_reduced();
}
/**
* Stores information about whether stock was reduced.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_stock_reduced( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_order_stock_reduced( $set );
$order->save();
}
/**
* Helper getter for `order_stock_reduced`.
*
* @param \WC_Order $order Order object.
* @return bool Whether stock was reduced.
*/
public function get_order_stock_reduced( $order ) {
return $this->get_stock_reduced( $order );
}
/**
* Helper setter for `order_stock_reduced`.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set Whether stock was reduced.
*/
public function set_order_stock_reduced( $order, $set ) {
$this->set_stock_reduced( $order, $set );
}
/**
* Get token ids for an order.
*
* @param WC_Order $order Order object.
* @return array
*/
public function get_payment_token_ids( $order ) {
/**
* We don't store _payment_tokens in props to preserve backward compatibility. In CPT data store, `_payment_tokens` is always fetched directly from DB instead of from prop.
*/
$payment_tokens = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $payment_tokens ) {
$payment_tokens = $payment_tokens[0]->meta_value;
}
if ( ! $payment_tokens && version_compare( $order->get_version(), '8.0.0', '<' ) ) {
// Before 8.0 we were incorrectly storing payment_tokens in the order meta. So we need to check there too.
$payment_tokens = get_post_meta( $order->get_id(), '_payment_tokens', true );
}
return array_filter( (array) $payment_tokens );
}
/**
* Update token ids for an order.
*
* @param WC_Order $order Order object.
* @param array $token_ids Payment token ids.
*/
public function update_payment_token_ids( $order, $token_ids ) {
$meta = new \WC_Meta_Data();
$meta->key = '_payment_tokens';
$meta->value = $token_ids;
$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta->id = $existing_meta->id;
$this->data_store_meta->update_meta( $order, $meta );
} else {
$this->data_store_meta->add_meta( $order, $meta );
}
}
/**
* Get amount already refunded.
*
* @param \WC_Order $order Order object.
*
* @return float Refunded amount.
*/
public function get_total_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
"
SELECT SUM( total_amount ) FROM $order_table
WHERE
type = %s AND
parent_order_id = %d
;
",
// phpcs:enable
'shop_order_refund',
$order->get_id()
)
);
return -1 * ( isset( $total ) ? $total : 0 );
}
/**
* Get the total tax refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_tax_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Get the total shipping refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_shipping_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('cost')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Finds an Order ID based on an order key.
*
* @param string $order_key An order key has generated by.
* @return int The ID of an order, or 0 if the order could not be found
*/
public function get_order_id_by_order_key( $order_key ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$op_table = self::get_operational_data_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT {$orders_table}.id FROM {$orders_table}
INNER JOIN {$op_table} ON {$op_table}.order_id = {$orders_table}.id
WHERE {$op_table}.order_key = %s AND {$op_table}.order_key != ''",
$order_key
)
);
// phpcs:enable
}
/**
* Return count of orders with a specific status.
*
* @param string $status Order status. Function wc_get_order_statuses() returns a list of valid statuses.
* @return int
*/
public function get_order_count( $status ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
return absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$orders_table} WHERE type = %s AND status = %s", 'shop_order', $status ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get all orders matching the passed in args.
*
* @deprecated 3.1.0 - Use {@see wc_get_orders} instead.
* @param array $args List of args passed to wc_get_orders().
* @return array|object
*/
public function get_orders( $args = array() ) {
wc_deprecated_function( __METHOD__, '3.1.0', 'Use wc_get_orders instead.' );
return wc_get_orders( $args );
}
/**
* Get unpaid orders last updated before the specified date.
*
* @param int $date Timestamp.
* @return array
*/
public function get_unpaid_orders( $date ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$order_types_sql = "('" . implode( "','", wc_get_order_types() ) . "')";
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$orders_table} WHERE
{$orders_table}.type IN {$order_types_sql}
AND {$orders_table}.status = %s
AND {$orders_table}.date_updated_gmt < %s",
'wc-pending',
gmdate( 'Y-m-d H:i:s', absint( $date ) )
)
);
// phpcs:enable
}
/**
* Search order data for a term and return matching order IDs.
*
* @param string $term Search term.
*
* @return int[] Array of order IDs.
*/
public function search_orders( $term ) {
$order_ids = wc_get_orders(
array(
's' => $term,
'return' => 'ids',
)
);
/**
* Provides an opportunity to modify the list of order IDs obtained during an order search.
*
* This hook is used for Custom Order Table queries. For Custom Post Type order searches, the corresponding hook
* is `woocommerce_shop_order_search_results`.
*
* @since 7.0.0
*
* @param int[] $order_ids Search results as an array of order IDs.
* @param string $term The search term.
*/
return array_map( 'intval', (array) apply_filters( 'woocommerce_cot_shop_order_search_results', $order_ids, $term ) );
}
/**
* Fetch order type for orders in bulk.
*
* @param array $order_ids Order IDs.
*
* @return array array( $order_id1 => $type1, ... ) Array for all orders.
*/
public function get_orders_type( $order_ids ) {
global $wpdb;
if ( empty( $order_ids ) ) {
return array();
}
$orders_table = self::get_orders_table_name();
$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, type FROM {$orders_table} WHERE id IN ( $order_ids_placeholder )",
$order_ids
)
);
// phpcs:enable
$order_types = array();
foreach ( $results as $row ) {
$order_types[ $row->id ] = $row->type;
}
return $order_types;
}
/**
* Get order type from DB.
*
* @param int $order_id Order ID.
*
* @return string Order type.
*/
public function get_order_type( $order_id ) {
$type = $this->get_orders_type( array( $order_id ) );
return $type[ $order_id ] ?? '';
}
/**
* Check if an order exists by id.
*
* @since 8.0.0
*
* @param int $order_id The order id to check.
* @return bool True if an order exists with the given name.
*/
public function order_exists( $order_id ) : bool {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT EXISTS (SELECT id FROM {$this->orders_table_name} WHERE id=%d)",
$order_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $exists;
}
/**
* Method to read an order from custom tables.
*
* @param \WC_Order $order Order object.
*
* @throws \Exception If passed order is invalid.
*/
public function read( &$order ) {
$orders_array = array( $order->get_id() => $order );
$this->read_multiple( $orders_array );
}
/**
* Reads multiple orders from custom tables in one pass.
*
* @since 6.9.0
* @param array[\WC_Order] $orders Order objects.
* @throws \Exception If passed an invalid order.
*/
public function read_multiple( &$orders ) {
$order_ids = array_keys( $orders );
$data = $this->get_order_data_for_ids( $order_ids );
if ( count( $data ) !== count( $order_ids ) ) {
throw new \Exception( __( 'Invalid order IDs in call to read_multiple()', 'woocommerce' ) );
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( ! $data_synchronizer instanceof DataSynchronizer ) {
return;
}
$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
if ( $data_sync_enabled ) {
/**
* Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
* This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
*
* @param bool $read_on_sync_enabled Whether to sync on read.
*
* @since 8.1.0
*/
$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $data_sync_enabled );
}
$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
foreach ( $data as $order_data ) {
$order_id = absint( $order_data->id );
$order = $orders[ $order_id ];
$this->init_order_record( $order, $order_id, $order_data );
if ( $data_sync_enabled && $this->should_sync_order( $order ) && isset( $post_orders[ $order_id ] ) ) {
self::$reading_order_ids[] = $order_id;
$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
}
}
}
/**
* Helper method to check whether to sync the order.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return bool Whether the order should be synced.
*/
private function should_sync_order( \WC_Abstract_Order $order ) : bool {
$draft_order = in_array( $order->get_status(), array( 'draft', 'auto-draft' ), true );
$already_synced = in_array( $order->get_id(), self::$reading_order_ids, true );
return ! $draft_order && ! $already_synced;
}
/**
* Helper method to initialize order object from DB data.
*
* @param \WC_Abstract_Order $order Order object.
* @param int $order_id Order ID.
* @param \stdClass $order_data Order data fetched from DB.
*
* @return void
*/
protected function init_order_record( \WC_Abstract_Order &$order, int $order_id, \stdClass $order_data ) {
$order->set_defaults();
$order->set_id( $order_id );
$filtered_meta_data = $this->filter_raw_meta_data( $order, $order_data->meta_data );
$order->init_meta_data( $filtered_meta_data );
$this->set_order_props_from_data( $order, $order_data );
$order->set_object_read( true );
}
/**
* For post based data stores, this was used to filter internal meta data. For custom tables, technically there is no internal meta data,
* (i.e. we store all core data as properties for the order, and not in meta data). So this method is a no-op.
*
* Except that some meta such as billing_address_index and shipping_address_index are infact stored in meta data, so we need to filter those out.
*
* However, declaring $internal_meta_keys is still required so that our backfill and other comparison checks works as expected.
*
* @param \WC_Data $object Object to filter meta data for.
* @param array $raw_meta_data Raw meta data.
*
* @return array Filtered meta data.
*/
public function filter_raw_meta_data( &$object, $raw_meta_data ) {
$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
$allowed_keys = array(
'_billing_address_index',
'_shipping_address_index',
);
$allowed_meta = array_filter(
$raw_meta_data,
function( $meta ) use ( $allowed_keys ) {
return in_array( $meta->meta_key, $allowed_keys, true );
}
);
return array_merge( $allowed_meta, $filtered_meta_data );
}
/**
* Sync order to/from posts tables if we are able to detect difference between order and posts but the sync is enabled.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object initialized from post.
*
* @return void
* @throws \Exception If passed an invalid order.
*/
private function maybe_sync_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
if ( ! $this->is_post_different_from_order( $order, $post_order ) ) {
return;
}
// Modified dates can be empty when the order is created but never updated again. Fallback to created date in those cases.
$order_modified_date = $order->get_date_modified() ?? $order->get_date_created();
$order_modified_date = is_null( $order_modified_date ) ? 0 : $order_modified_date->getTimestamp();
$post_order_modified_date = $post_order->get_date_modified() ?? $post_order->get_date_created();
$post_order_modified_date = is_null( $post_order_modified_date ) ? 0 : $post_order_modified_date->getTimestamp();
/**
* We are here because there was difference in posts and order data, although the sync is enabled.
* When order modified date is more recent than post modified date, it can only mean that COT definitely has more updated version of the order.
*
* In a case where post meta was updated (without updating post_modified date), post_modified would be equal to order_modified date.
*
* So we write back to the order table when order modified date is more recent than post modified date. Otherwise, we write to the post table.
*/
if ( $post_order_modified_date >= $order_modified_date ) {
$this->migrate_post_record( $order, $post_order );
}
}
/**
* Get the post type order representation.
*
* @param \WP_Post $post Post object.
*
* @return \WC_Order Order object.
*/
private function get_cpt_order( $post ) {
$cpt_order = new \WC_Order();
$cpt_order->set_id( $post->ID );
$cpt_data_store = $this->get_cpt_data_store_instance();
$cpt_data_store->read( $cpt_order );
return $cpt_order;
}
/**
* Helper function to get posts data for an order in bullk. We use to this to compute posts object in bulk so that we can compare it with COT data.
*
* @param array $orders List of orders mapped by $order_id.
*
* @return array List of posts.
*/
private function get_post_orders_for_ids( array $orders ): array {
$order_ids = array_keys( $orders );
// We have to bust meta cache, otherwise we will just get the meta cached by OrderTableDataStore.
foreach ( $order_ids as $order_id ) {
wp_cache_delete( WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
}
$cpt_stores = array();
$cpt_store_orders = array();
foreach ( $orders as $order_id => $order ) {
$table_data_store = $order->get_data_store();
$cpt_data_store = $table_data_store->get_cpt_data_store_instance();
$cpt_store_class_name = get_class( $cpt_data_store );
if ( ! isset( $cpt_stores[ $cpt_store_class_name ] ) ) {
$cpt_stores[ $cpt_store_class_name ] = $cpt_data_store;
$cpt_store_orders[ $cpt_store_class_name ] = array();
}
$cpt_store_orders[ $cpt_store_class_name ][ $order_id ] = $order;
}
$cpt_orders = array();
foreach ( $cpt_stores as $cpt_store_name => $cpt_store ) {
// Prime caches if we can.
if ( method_exists( $cpt_store, 'prime_caches_for_orders' ) ) {
$cpt_store->prime_caches_for_orders( array_keys( $cpt_store_orders[ $cpt_store_name ] ), array() );
}
foreach ( $cpt_store_orders[ $cpt_store_name ] as $order_id => $order ) {
$cpt_order_class_name = wc_get_order_type( $order->get_type() )['class_name'];
$cpt_order = new $cpt_order_class_name();
try {
$cpt_order->set_id( $order_id );
$cpt_store->read( $cpt_order );
$cpt_orders[ $order_id ] = $cpt_order;
} catch ( Exception $e ) {
// If the post record has been deleted (for instance, by direct query) then an exception may be thrown.
$this->error_logger->warning(
sprintf(
/* translators: %1$d order ID. */
__( 'Unable to load the post record for order %1$d', 'woocommerce' ),
$order_id
),
array(
'exception_code' => $e->getCode(),
'exception_msg' => $e->getMessage(),
'origin' => __METHOD__,
)
);
}
}
}
return $cpt_orders;
}
/**
* Computes whether post has been updated after last order. Tries to do it as efficiently as possible.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return bool True if post is different than order.
*/
private function is_post_different_from_order( $order, $post_order ): bool {
if ( ArrayUtil::deep_compare_array_diff( $order->get_base_data(), $post_order->get_base_data(), false ) ) {
return true;
}
$meta_diff = $this->get_diff_meta_data_between_orders( $order, $post_order );
if ( ! empty( $meta_diff ) ) {
return true;
}
return false;
}
/**
* Migrate meta data from post to order.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return array List of meta data that was migrated.
*/
private function migrate_meta_data_from_post_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
$diff = $this->get_diff_meta_data_between_orders( $order, $post_order, true );
$order->save_meta_data();
return $diff;
}
/**
* Helper function to compute diff between metadata of post and cot data for an order.
*
* Also provides an option to sync the metadata as well, since we are already computing the diff.
*
* @param \WC_Abstract_Order $order1 Order object read from posts.
* @param \WC_Abstract_Order $order2 Order object read from COT.
* @param bool $sync Whether to also sync the meta data.
*
* @return array Difference between post and COT meta data.
*/
private function get_diff_meta_data_between_orders( \WC_Abstract_Order &$order1, \WC_Abstract_Order $order2, $sync = false ): array {
$order1_meta = ArrayUtil::select( $order1->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order2_meta = ArrayUtil::select( $order2->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order1_meta_by_key = ArrayUtil::select_as_assoc( $order1_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$order2_meta_by_key = ArrayUtil::select_as_assoc( $order2_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$diff = array();
foreach ( $order1_meta_by_key as $key => $value ) {
if ( in_array( $key, $this->internal_meta_keys, true ) ) {
// These should have already been verified in the base data comparison.
continue;
}
$order1_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
if ( ! array_key_exists( $key, $order2_meta_by_key ) ) {
$sync && $order1->delete_meta_data( $key );
$diff[ $key ] = $order1_values;
unset( $order2_meta_by_key[ $key ] );
continue;
}
$order2_values = ArrayUtil::select( $order2_meta_by_key[ $key ], 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
$new_diff = ArrayUtil::deep_assoc_array_diff( $order1_values, $order2_values );
if ( ! empty( $new_diff ) && $sync ) {
if ( count( $order2_values ) > 1 ) {
$sync && $order1->delete_meta_data( $key );
foreach ( $order2_values as $post_order_value ) {
$sync && $order1->add_meta_data( $key, $post_order_value, false );
}
} else {
$sync && $order1->update_meta_data( $key, $order2_values[0] );
}
$diff[ $key ] = $new_diff;
unset( $order2_meta_by_key[ $key ] );
}
}
foreach ( $order2_meta_by_key as $key => $value ) {
if ( array_key_exists( $key, $order1_meta_by_key ) || in_array( $key, $this->internal_meta_keys, true ) ) {
continue;
}
$order2_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
foreach ( $order2_values as $meta_value ) {
$sync && $order1->add_meta_data( $key, $meta_value );
}
$diff[ $key ] = $order2_values;
}
return $diff;
}
/**
* Log difference between post and COT data for an order.
*
* @param array $diff Difference between post and COT data.
*
* @return void
*/
private function log_diff( array $diff ): void {
$this->error_logger->notice( 'Diff found: ' . wp_json_encode( $diff, JSON_PRETTY_PRINT ) );
}
/**
* Migrate post record from a given order object.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts.
*
* @return void
*/
private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
$this->migrate_meta_data_from_post_order( $order, $post_order );
$post_order_base_data = $post_order->get_base_data();
foreach ( $post_order_base_data as $key => $value ) {
$this->set_order_prop( $order, $key, $value );
}
$this->persist_updates( $order, false );
}
/**
* Sets order properties based on a row from the database.
*
* @param \WC_Abstract_Order $order The order object.
* @param object $order_data A row of order data from the database.
*/
protected function set_order_props_from_data( &$order, $order_data ) {
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
foreach ( $column_mapping as $column_name => $prop_details ) {
if ( ! isset( $prop_details['name'] ) ) {
continue;
}
$prop_value = $order_data->{$prop_details['name']};
if ( is_null( $prop_value ) ) {
continue;
}
try {
if ( 'date' === $prop_details['type'] ) {
$prop_value = $this->string_to_timestamp( $prop_value );
}
$this->set_order_prop( $order, $prop_details['name'], $prop_value );
} catch ( \Exception $e ) {
$order_id = $order->get_id();
$this->error_logger->warning(
sprintf(
/* translators: %1$d = peoperty name, %2$d = order ID, %3$s = error message. */
__( 'Error when setting property \'%1$s\' for order %2$d: %3$s', 'woocommerce' ),
$prop_details['name'],
$order_id,
$e->getMessage()
),
array(
'exception_code' => $e->getCode(),
'exception_msg' => $e->getMessage(),
'origin' => __METHOD__,
'order_id' => $order_id,
'property_name' => $prop_details['name'],
)
);
}
}
}
}
/**
* Set order prop if a setter exists in either the order object or in the data store.
*
* @param \WC_Abstract_Order $order Order object.
* @param string $prop_name Property name.
* @param mixed $prop_value Property value.
*
* @return bool True if the property was set, false otherwise.
*/
private function set_order_prop( \WC_Abstract_Order $order, string $prop_name, $prop_value ) {
$prop_setter_function_name = "set_{$prop_name}";
if ( is_callable( array( $order, $prop_setter_function_name ) ) ) {
return $order->{$prop_setter_function_name}( $prop_value );
} elseif ( is_callable( array( $this, $prop_setter_function_name ) ) ) {
return $this->{$prop_setter_function_name}( $order, $prop_value, false );
}
return false;
}
/**
* Return order data for a single order ID.
*
* @param int $id Order ID.
*
* @return object|\WP_Error DB order object or WP_Error.
*/
private function get_order_data_for_id( $id ) {
$results = $this->get_order_data_for_ids( array( $id ) );
return is_array( $results ) && count( $results ) > 0 ? $results[ $id ] : $results;
}
/**
* Return order data for multiple IDs.
*
* @param array $ids List of order IDs.
*
* @return \stdClass[]|object|null DB Order objects or error.
*/
protected function get_order_data_for_ids( $ids ) {
global $wpdb;
if ( ! $ids || empty( $ids ) ) {
return array();
}
$table_aliases = array(
'orders' => $this->get_order_table_alias(),
'billing_address' => $this->get_address_table_alias( 'billing' ),
'shipping_address' => $this->get_address_table_alias( 'shipping' ),
'operational_data' => $this->get_op_table_alias(),
);
$order_table_alias = $table_aliases['orders'];
$order_table_query = $this->get_order_table_select_statement();
$id_placeholder = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$order_meta_table = self::get_meta_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
$table_data = $wpdb->get_results(
$wpdb->prepare(
"$order_table_query WHERE $order_table_alias.id in ( $id_placeholder )",
$ids
)
);
// phpcs:enable
$meta_data_query = $this->get_order_meta_select_statement();
$order_data = array();
$meta_data = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_data_query and $order_meta_table is autogenerated and should already be prepared. $id_placeholder is already prepared.
"$meta_data_query WHERE $order_meta_table.order_id in ( $id_placeholder )",
$ids
)
);
foreach ( $table_data as $table_datum ) {
$id = $table_datum->{"{$order_table_alias}_id"};
$order_data[ $id ] = new \stdClass();
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
$table_alias = $table_aliases[ $table_name ];
// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
foreach ( $column_mappings as $field => $map ) {
$field_name = $map['name'] ?? "{$table_name}_$field";
if ( property_exists( $table_datum, $field_name ) ) {
$field_value = $table_datum->{ $field_name }; // Unique column, field name is different prop name.
} elseif ( property_exists( $table_datum, "{$table_alias}_$field" ) ) {
$field_value = $table_datum->{"{$table_alias}_$field"}; // Non-unique column (billing, shipping etc).
} else {
$field_value = $table_datum->{ $field }; // Unique column, field name is same as prop name.
}
$order_data[ $id ]->{$field_name} = $field_value;
}
}
$order_data[ $id ]->id = $id;
$order_data[ $id ]->meta_data = array();
}
foreach ( $meta_data as $meta_datum ) {
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Not a meta query.
$order_data[ $meta_datum->order_id ]->meta_data[] = (object) array(
'meta_id' => $meta_datum->id,
'meta_key' => $meta_datum->meta_key,
'meta_value' => $meta_datum->meta_value,
);
// phpcs:enable
}
return $order_data;
}
/**
* Helper method to generate combined select statement.
*
* @return string Select SQL statement to fetch order.
*/
private function get_order_table_select_statement() {
$order_table = $this::get_orders_table_name();
$order_table_alias = $this->get_order_table_alias();
$billing_address_table_alias = $this->get_address_table_alias( 'billing' );
$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
$op_data_table_alias = $this->get_op_table_alias();
$billing_address_clauses = $this->join_billing_address_table_to_order_query( $order_table_alias, $billing_address_table_alias );
$shipping_address_clauses = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
$operational_data_clauses = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );
/**
* We fully spell out address table columns because they have duplicate columns for billing and shipping and would be overwritten if we don't spell them out. There is not such duplication in the operational data table and orders table, so select with `alias`.* is fine.
* We do spell ID columns manually, as they are duplicate.
*/
return "
SELECT $order_table_alias.id as o_id, $op_data_table_alias.id as p_id, $order_table_alias.*, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, $op_data_table_alias.*
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
LEFT JOIN {$operational_data_clauses['join']}
";
}
/**
* Helper function to generate select statement for fetching metadata in bulk.
*
* @return string Select SQL statement to fetch order metadata.
*/
private function get_order_meta_select_statement() {
$order_meta_table = self::get_meta_table_name();
return "
SELECT $order_meta_table.id, $order_meta_table.order_id, $order_meta_table.meta_key, $order_meta_table.meta_value
FROM $order_meta_table
";
}
/**
* Helper method to generate join query for billing addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for billing address table.
*/
private function join_billing_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'billing', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join query for shipping addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for shipping address table.
*/
private function join_shipping_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'shipping', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join and select query for address table.
*
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $order_table_alias Alias of order table to use.
* @param string $address_table_alias Alias for address table to use.
*
* @return array Select and join statements for address table.
*/
private function join_address_table_order_query( $address_type, $order_table_alias, $address_table_alias ) {
global $wpdb;
$address_table = $this::get_addresses_table_name();
$column_props_map = 'billing' === $address_type ? $this->billing_address_column_mapping : $this->shipping_address_column_mapping;
$clauses = $this->generate_select_and_join_clauses( $order_table_alias, $address_table, $address_table_alias, $column_props_map );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $clauses['join'] and $address_table_alias are hardcoded.
$clauses['join'] = $wpdb->prepare(
"{$clauses['join']} AND $address_table_alias.address_type = %s",
$address_type
);
// phpcs:enable
return array(
'select' => $clauses['select'],
'join' => $clauses['join'],
);
}
/**
* Helper method to join order operational data table.
*
* @param string $order_table_alias Alias to use for order table.
* @param string $operational_table_alias Alias to use for operational data table.
*
* @return array Select and join queries for operational data table.
*/
private function join_operational_data_table_to_order_query( $order_table_alias, $operational_table_alias ) {
$operational_data_table = $this::get_operational_data_table_name();
return $this->generate_select_and_join_clauses(
$order_table_alias,
$operational_data_table,
$operational_table_alias,
$this->operational_data_column_mapping
);
}
/**
* Helper method to generate join and select clauses.
*
* @param string $order_table_alias Alias for order table.
* @param string $table Table to join.
* @param string $table_alias Alias for table to join.
* @param array[] $column_props_map Column to prop map for table to join.
*
* @return array Select and join queries.
*/
private function generate_select_and_join_clauses( $order_table_alias, $table, $table_alias, $column_props_map ) {
// Add aliases to column names so they will be unique when fetching.
$select_clause = $this->generate_select_clause_for_props( $table_alias, $column_props_map );
$join_clause = "$table $table_alias ON $table_alias.order_id = $order_table_alias.id";
return array(
'select' => $select_clause,
'join' => $join_clause,
);
}
/**
* Helper method to generate select clause for props.
*
* @param string $table_alias Alias for table.
* @param array[] $props Props to column mapping for table.
*
* @return string Select clause.
*/
private function generate_select_clause_for_props( $table_alias, $props ) {
$select_clauses = array();
foreach ( $props as $column_name => $prop_details ) {
$select_clauses[] = isset( $prop_details['name'] ) ? "$table_alias.$column_name as {$prop_details['name']}" : "$table_alias.$column_name as {$table_alias}_$column_name";
}
return implode( ', ', $select_clauses );
}
/**
* Persists order changes to the database.
*
* @param \WC_Abstract_Order $order The order.
* @param bool $force_all_fields Force saving all fields to DB and just changed.
*
* @throws \Exception If order data is not valid.
*
* @since 6.8.0
*/
protected function persist_order_to_db( &$order, bool $force_all_fields = false ) {
$context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update';
$data_sync = wc_get_container()->get( DataSynchronizer::class );
if ( 'create' === $context ) {
$post_id = wp_insert_post(
array(
'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
'post_status' => 'draft',
'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
)
);
if ( ! $post_id ) {
throw new \Exception( __( 'Could not create order in posts table.', 'woocommerce' ) );
}
$order->set_id( $post_id );
}
$only_changes = ! $force_all_fields && 'update' === $context;
// Figure out what needs to be updated in the database.
$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );
// Persist changes.
foreach ( $db_updates as $update ) {
// Make sure 'data' and 'format' entries match before passing to $wpdb.
ksort( $update['data'] );
ksort( $update['format'] );
$result = $this->database_util->insert_on_duplicate_key_update(
$update['table'],
$update['data'],
array_values( $update['format'] )
);
if ( false === $result ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Could not persist order to database table "%s".', 'woocommerce' ), $update['table'] ) );
}
}
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
$default_taxonomies = $this->init_default_taxonomies( $order, array() );
$this->set_custom_taxonomies( $order, $default_taxonomies );
}
/**
* Set default taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set default taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return array Sanitized tax input with default taxonomies.
*/
public function init_default_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( 'auto-draft' === $order->get_status() ) {
return $sanitized_tax_input;
}
foreach ( get_object_taxonomies( $order->get_type(), 'object' ) as $taxonomy => $tax_object ) {
if ( empty( $tax_object->default_term ) ) {
return $sanitized_tax_input;
}
// Filter out empty terms.
if ( isset( $sanitized_tax_input[ $taxonomy ] ) && is_array( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = array_filter( $sanitized_tax_input[ $taxonomy ] );
}
// Passed custom taxonomy list overwrites the existing list if not empty.
$terms = wp_get_object_terms( $order->get_id(), $taxonomy, array( 'fields' => 'ids' ) );
if ( ! empty( $terms ) && empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = $terms;
}
if ( empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$default_term_id = get_option( 'default_term_' . $taxonomy );
if ( ! empty( $default_term_id ) ) {
$sanitized_tax_input[ $taxonomy ] = array( (int) $default_term_id );
}
}
}
return $sanitized_tax_input;
}
/**
* Set custom taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set custom taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return void
*/
public function set_custom_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( empty( $sanitized_tax_input ) ) {
return;
}
foreach ( $sanitized_tax_input as $taxonomy => $tags ) {
$taxonomy_obj = get_taxonomy( $taxonomy );
if ( ! $taxonomy_obj ) {
/* translators: %s: Taxonomy name. */
_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Invalid taxonomy: %s.', 'woocommerce' ), $taxonomy ) ), '7.9.0' );
continue;
}
// array = hierarchical, string = non-hierarchical.
if ( is_array( $tags ) ) {
$tags = array_filter( $tags );
}
if ( current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
wp_set_post_terms( $order->get_id(), $tags, $taxonomy );
}
}
}
/**
* Generates an array of rows with all the details required to insert or update an order in the database.
*
* @param \WC_Abstract_Order $order The order.
* @param string $context The context: 'create' or 'update'.
* @param boolean $only_changes Whether to consider only changes in the order for generating the rows.
*
* @return array
* @throws \Exception When invalid data is found for the given context.
*
* @since 6.8.0
*/
protected function get_db_rows_for_order( \WC_Abstract_Order $order, string $context = 'create', bool $only_changes = false ): array {
$result = array();
$row = $this->get_db_row_from_order( $order, $this->order_column_mapping, $only_changes );
if ( 'create' === $context && ! $row ) {
throw new \Exception( 'No data for new record.' ); // This shouldn't occur.
}
if ( $row ) {
$result[] = array(
'table' => self::get_orders_table_name(),
'data' => array_merge(
$row['data'],
array(
'id' => $order->get_id(),
'type' => $order->get_type(),
)
),
'format' => array_merge(
$row['format'],
array(
'id' => '%d',
'type' => '%s',
)
),
);
}
// wc_order_operational_data.
$row = $this->get_db_row_from_order( $order, $this->operational_data_column_mapping, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_operational_data_table_name(),
'data' => array_merge( $row['data'], array( 'order_id' => $order->get_id() ) ),
'format' => array_merge( $row['format'], array( 'order_id' => '%d' ) ),
);
}
// wc_order_addresses.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$row = $this->get_db_row_from_order( $order, $this->{$address_type . '_address_column_mapping'}, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_addresses_table_name(),
'data' => array_merge(
$row['data'],
array(
'order_id' => $order->get_id(),
'address_type' => $address_type,
)
),
'format' => array_merge(
$row['format'],
array(
'order_id' => '%d',
'address_type' => '%s',
)
),
);
}
}
/**
* Allow third parties to include rows that need to be inserted/updated in custom tables when persisting an order.
*
* @since 6.8.0
*
* @param array Array of rows to be inserted/updated when persisting an order. Each entry should be an array with
* keys 'table', 'data' (the row), 'format' (row format), 'where' and 'where_format'.
* @param \WC_Order The order object.
* @param string The context of the operation: 'create' or 'update'.
*/
$ext_rows = apply_filters( 'woocommerce_orders_table_datastore_extra_db_rows_for_order', array(), $order, $context );
return array_merge( $result, $ext_rows );
}
/**
* Produces an array with keys 'row' and 'format' that can be passed to `$wpdb->update()` as the `$data` and
* `$format` parameters. Values are taken from the order changes array and properly formatted for inclusion in the
* database.
*
* @param \WC_Abstract_Order $order Order.
* @param array $column_mapping Table column mapping.
* @param bool $only_changes Whether to consider only changes in the order object or all fields.
* @return array
*
* @since 6.8.0
*/
protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );
// Make sure 'status' is correctly prefixed.
if ( array_key_exists( 'status', $column_mapping ) && array_key_exists( 'status', $changes ) ) {
$changes['status'] = $this->get_post_status( $order );
}
$row = array();
$row_format = array();
foreach ( $column_mapping as $column => $details ) {
if ( ! isset( $details['name'] ) || ! array_key_exists( $details['name'], $changes ) ) {
continue;
}
$row[ $column ] = $this->database_util->format_object_value_for_db( $changes[ $details['name'] ], $details['type'] );
$row_format[ $column ] = $this->database_util->get_wpdb_format_for_type( $details['type'] );
}
if ( ! $row ) {
return false;
}
return array(
'data' => $row,
'format' => $row_format,
);
}
/**
* Method to delete an order from the database.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$order, $args = array() ) {
$order_id = $order->get_id();
if ( ! $order_id ) {
return;
}
$args = wp_parse_args(
$args,
array(
'force_delete' => false,
'suppress_filters' => false,
)
);
$do_filters = ! $args['suppress_filters'];
if ( $args['force_delete'] ) {
if ( $do_filters ) {
/**
* Fires immediately before an order is deleted from the database.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be deleted.
* @param WC_Order $order Instance of the order that is about to be deleted.
*/
do_action( 'woocommerce_before_delete_order', $order_id, $order );
}
$this->upshift_or_delete_child_orders( $order );
$this->delete_order_data_from_custom_order_tables( $order_id );
$this->delete_items( $order );
$order->set_id( 0 );
/** We can delete the post data if:
* 1. The HPOS table is authoritative and synchronization is enabled.
* 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
*
* In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
*/
$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;
if ( $orders_table_is_authoritative ) {
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $order_id );
} else {
$this->handle_order_deletion_with_sync_disabled( $order_id );
}
}
if ( $do_filters ) {
/**
* Fires immediately after an order is deleted.
*
* @since
*
* @param int $order_id ID of the order that has been deleted.
*/
do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
} else {
if ( $do_filters ) {
/**
* Fires immediately before an order is trashed.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be trashed.
* @param WC_Order $order Instance of the order that is about to be trashed.
*/
do_action( 'woocommerce_before_trash_order', $order_id, $order );
}
$this->trash_order( $order );
if ( $do_filters ) {
/**
* Fires immediately after an order is trashed.
*
* @since
*
* @param int $order_id ID of the order that has been trashed.
*/
do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
}
}
/**
* Handles the deletion of an order from the orders table when sync is disabled:
*
* If the corresponding row in the posts table is of placeholder type,
* it's just deleted; otherwise a "deleted_from" record is created in the meta table
* and the sync process will detect these and take care of deleting the appropriate post records.
*
* @param int $order_id Th id of the order that has been deleted from the orders table.
* @return void
*/
protected function handle_order_deletion_with_sync_disabled( $order_id ): void {
global $wpdb;
$post_type = $wpdb->get_var(
$wpdb->prepare( "SELECT post_type FROM {$wpdb->posts} WHERE ID=%d", $order_id )
);
if ( DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post_type ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->posts} WHERE ID=%d OR post_parent=%d",
$order_id,
$order_id
)
);
} else {
// phpcs:disable WordPress.DB.SlowDBQuery
$wpdb->insert(
self::get_meta_table_name(),
array(
'order_id' => $order_id,
'meta_key' => DataSynchronizer::DELETED_RECORD_META_KEY,
'meta_value' => DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery
// Note that at this point upshift_or_delete_child_orders will already have been invoked,
// thus all the child orders either still exist but have a different parent id,
// or have been deleted and got their own deletion record already.
// So there's no need to do anything about them.
}
}
/**
* Set the parent id of child orders to the parent order's parent if the post type
* for the order is hierarchical, just delete the child orders otherwise.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return void
*/
private function upshift_or_delete_child_orders( $order ) : void {
global $wpdb;
$order_table = self::get_orders_table_name();
$order_parent_id = $order->get_parent_id();
if ( $this->legacy_proxy->call_function( 'is_post_type_hierarchical', $order->get_type() ) ) {
$wpdb->update(
$order_table,
array( 'parent_order_id' => $order_parent_id ),
array( 'parent_order_id' => $order->get_id() ),
array( '%d' ),
array( '%d' )
);
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$child_order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM $order_table WHERE parent_order_id=%d",
$order->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
foreach ( $child_order_ids as $child_order_id ) {
$child_order = wc_get_order( $child_order_id );
if ( $child_order ) {
$child_order->delete( true );
}
}
}
}
/**
* Trashes an order.
*
* @param WC_Order $order The order object.
*
* @return void
*/
public function trash_order( $order ) {
global $wpdb;
if ( 'trash' === $order->get_status( 'edit' ) ) {
return;
}
$trash_metadata = array(
'_wp_trash_meta_status' => 'wc-' . $order->get_status( 'edit' ),
'_wp_trash_meta_time' => time(),
);
$wpdb->update(
self::get_orders_table_name(),
array(
'status' => 'trash',
'date_updated_gmt' => current_time( 'Y-m-d H:i:s', true ),
),
array( 'id' => $order->get_id() ),
array( '%s', '%s' ),
array( '%d' )
);
$order->set_status( 'trash' );
foreach ( $trash_metadata as $meta_key => $meta_value ) {
$this->add_meta(
$order,
(object) array(
'key' => $meta_key,
'value' => $meta_value,
)
);
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
wp_trash_post( $order->get_id() );
}
}
/**
* Attempts to restore the specified order back to its original status (after having been trashed).
*
* @param WC_Order $order The order to be untrashed.
*
* @return bool If the operation was successful.
*/
public function untrash_order( WC_Order $order ): bool {
$id = $order->get_id();
$status = $order->get_status();
if ( 'trash' !== $status ) {
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Order %1$d cannot be restored from the trash: it has already been restored to status "%2$s".', 'woocommerce' ),
$id,
$status
)
);
return false;
}
$previous_status = $order->get_meta( '_wp_trash_meta_status' );
$valid_statuses = wc_get_order_statuses();
$previous_state_is_invalid = ! array_key_exists( $previous_status, $valid_statuses );
$pending_is_valid_status = array_key_exists( 'wc-pending', $valid_statuses );
if ( $previous_state_is_invalid && $pending_is_valid_status ) {
// If the previous status is no longer valid, let's try to restore it to "pending" instead.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It has been restored to "pending" status instead.', 'woocommerce' ),
$id,
$previous_status
)
);
$previous_status = 'pending';
} elseif ( $previous_state_is_invalid ) {
// If we cannot restore to pending, we should probably stand back and let the merchant intervene some other way.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It could not be restored.', 'woocommerce' ),
$id,
$previous_status
)
);
return false;
}
/**
* Fires before an order is restored from the trash.
*
* @since 7.2.0
*
* @param int $order_id Order ID.
* @param string $previous_status The status of the order before it was trashed.
*/
do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );
$order->set_status( $previous_status );
$order->save();
// Was the status successfully restored? Let's clean up the meta and indicate success...
if ( 'wc-' . $order->get_status() === $previous_status ) {
$order->delete_meta_data( '_wp_trash_meta_status' );
$order->delete_meta_data( '_wp_trash_meta_time' );
$order->delete_meta_data( '_wp_trash_meta_comments_status' );
$order->save_meta_data();
return true;
}
// ...Or log a warning and bail.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Something went wrong when trying to restore order %d from the trash. It could not be restored.', 'woocommerce' ),
$id
)
);
return false;
}
/**
* Deletes order data from custom order tables.
*
* @param int $order_id The order ID.
* @return void
*/
public function delete_order_data_from_custom_order_tables( $order_id ) {
global $wpdb;
$order_cache = wc_get_container()->get( OrderCache::class );
// Delete COT-specific data.
foreach ( $this->get_all_table_names() as $table ) {
$wpdb->delete(
$table,
( self::get_orders_table_name() === $table )
? array( 'id' => $order_id )
: array( 'order_id' => $order_id ),
array( '%d' )
);
$order_cache->remove( $order_id );
}
}
/**
* Method to create an order in the database.
*
* @param \WC_Order $order Order object.
*/
public function create( &$order ) {
if ( '' === $order->get_order_key() ) {
$order->set_order_key( wc_generate_order_key() );
}
$this->persist_save( $order );
// Do not fire 'woocommerce_new_order' for draft statuses for backwards compatibility.
if ( 'auto-draft' === $order->get_status( 'edit' ) ) {
return;
}
/**
* Fires when a new order is created.
*
* @since 2.7.0
*
* @param int Order ID.
* @param \WC_Order Order object.
*/
do_action( 'woocommerce_new_order', $order->get_id(), $order );
}
/**
* Helper method responsible for persisting new data to order table.
*
* This should not contain and specific meta or actions, so that it can be used other order types safely.
*
* @param \WC_Order $order Order object.
* @param bool $force_all_fields Force update all fields, instead of calculating and updating only changed fields.
* @param bool $backfill Whether to backfill data to post datastore.
*
* @return void
*
* @throws \Exception When unable to save data.
*/
protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) {
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
$order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() );
if ( ! $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
if ( ! $order->get_date_modified( 'edit' ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order, $force_all_fields );
$this->update_order_meta( $order );
$order->save_meta_data();
$order->apply_changes();
if ( $backfill ) {
self::$backfilling_order_ids[] = $order->get_id();
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
$this->clear_caches( $order );
}
/**
* Method to update an order in the database.
*
* @param \WC_Order $order Order object.
*/
public function update( &$order ) {
$previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status' );
$changes = $order->get_changes();
// Before updating, ensure date paid is set if missing.
if (
! $order->get_date_paid( 'edit' )
&& version_compare( $order->get_version( 'edit' ), '3.0', '<' )
&& $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
) {
$order->set_date_paid( $order->get_date_created( 'edit' ) );
}
if ( null === $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
// Fetch changes.
$changes = $order->get_changes();
$this->persist_updates( $order );
// Update download permissions if necessary.
if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
$data_store = \WC_Data_Store::load( 'customer-download' );
$data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() );
}
// Mark user account as active.
if ( array_key_exists( 'customer_id', $changes ) ) {
wc_update_user_last_active( $order->get_customer_id() );
}
$order->apply_changes();
$this->clear_caches( $order );
// For backwards compatibility, moving an auto-draft order to a valid status triggers the 'woocommerce_new_order' hook.
if ( ! empty( $changes['status'] ) && 'auto-draft' === $previous_status ) {
do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
return;
}
// For backwards compat with CPT, trashing/untrashing and changing previously datastore-level props does not trigger the update hook.
if ( ( ! empty( $changes['status'] ) && in_array( 'trash', array( $changes['status'], $previous_status ), true ) )
|| ! array_diff_key( $changes, array_flip( $this->get_post_data_store_for_backfill()->get_internal_data_store_key_getters() ) ) ) {
return;
}
do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
/**
* Proxy to udpating order meta. Here for backward compatibility reasons.
*
* @param \WC_Order $order Order object.
*
* @return void
*/
protected function update_post_meta( &$order ) {
$this->update_order_meta( $order );
}
/**
* Helper method that is responsible for persisting order updates to the database.
*
* This is expected to be reused by other order types, and should not contain any specific metadata updates or actions.
*
* @param \WC_Order $order Order object.
* @param bool $backfill Whether to backfill data to post tables.
*
* @return array $changes Array of changes.
*
* @throws \Exception When unable to persist order.
*/
protected function persist_updates( &$order, $backfill = true ) {
// Fetch changes.
$changes = $order->get_changes();
if ( ! isset( $changes['date_modified'] ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order );
$order->save_meta_data();
if ( $backfill ) {
self::$backfilling_order_ids[] = $order->get_id();
$this->clear_caches( $order );
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
return $changes;
}
/**
* Helper method to check whether to backfill post record.
*
* @return bool
*/
private function should_backfill_post_record() {
$data_sync = wc_get_container()->get( DataSynchronizer::class );
return $data_sync->data_sync_is_enabled();
}
/**
* Helper function to decide whether to backfill post record.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return void
*/
private function maybe_backfill_post_record( $order ) {
if ( $this->should_backfill_post_record() ) {
$this->backfill_post_record( $order );
}
}
/**
* Helper method that updates post meta based on an order object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $order Order object.
*
* @since 7.0.0
*/
public function update_order_meta( &$order ) {
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
}
/**
* Helper function to update billing and shipping address metadata.
*
* @param \WC_Abstract_Order $order Order Object.
* @param array $changes Array of changes.
*
* @return void
*/
private function update_address_index_meta( $order, $changes ) {
// If address changed, store concatenated version to make searches faster.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
if ( isset( $changes[ $address_type ] ) ) {
$order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) );
}
}
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
* Pass $coupon_id if key for only one of the coupon is needed.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
*/
public function get_coupon_held_keys( $order, $coupon_id = null ) {
$held_keys = $order->get_meta( '_coupon_held_keys' );
if ( $coupon_id ) {
return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null;
}
return $held_keys;
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return mixed
*/
public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) {
$held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' );
if ( $coupon_id ) {
return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null;
}
return $held_keys_for_user;
}
/**
* Add/Update list of meta keys that are currently being used by this order to hold a coupon.
* This is used to figure out what all meta entries we should delete when order is cancelled/completed.
*
* @param WC_Order $order Order object.
* @param array $held_keys Array of coupon_code => meta_key.
* @param array $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
*
* @return mixed
*/
public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) {
if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
$order->update_meta_data( '_coupon_held_keys', $held_keys );
}
if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
$order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
}
}
/**
* Release all coupons held by this order.
*
* @param WC_Order $order Current order object.
* @param bool $save Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
*/
public function release_held_coupons( $order, $save = true ) {
$coupon_held_keys = $this->get_coupon_held_keys( $order );
if ( is_array( $coupon_held_keys ) ) {
foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys' );
$coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order );
if ( is_array( $coupon_held_keys_for_users ) ) {
foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys_for_users' );
if ( $save ) {
$order->save_meta_data();
}
}
/**
* Performs actual query to get orders. Uses `OrdersTableQuery` to build and generate the query.
*
* @param array $query_vars Query variables.
*
* @return array|object List of orders and count of orders.
*/
public function query( $query_vars ) {
if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) {
$query_vars['no_found_rows'] = true;
}
if ( isset( $query_vars['anonymized'] ) ) {
$query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
if ( $query_vars['anonymized'] ) {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'value' => 'yes',
);
} else {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'compare' => 'NOT EXISTS',
);
}
}
try {
$query = new OrdersTableQuery( $query_vars );
} catch ( \Exception $e ) {
$query = (object) array(
'orders' => array(),
'found_orders' => 0,
'max_num_pages' => 0,
);
}
if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) {
$orders = $query->orders;
} else {
$orders = WC()->order_factory->get_orders( $query->orders );
}
if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
return (object) array(
'orders' => $orders,
'total' => $query->found_orders,
'max_num_pages' => $query->max_num_pages,
);
}
return $orders;
}
//phpcs:enable Squiz.Commenting, Generic.Commenting
/**
* Get the SQL needed to create all the tables needed for the custom orders table feature.
*
* @return string
*/
public function get_database_schema() {
global $wpdb;
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
$orders_table_name = $this->get_orders_table_name();
$addresses_table_name = $this->get_addresses_table_name();
$operational_data_table_name = $this->get_operational_data_table_name();
$meta_table = $this->get_meta_table_name();
$max_index_length = $this->database_util->get_max_index_length();
$composite_meta_value_index_length = max( $max_index_length - 8 - 100 - 1, 20 ); // 8 for order_id, 100 for meta_key, 10 minimum for meta_value.
$composite_customer_id_email_length = max( $max_index_length - 20, 20 ); // 8 for customer_id, 20 minimum for email.
$sql = "
CREATE TABLE $orders_table_name (
id bigint(20) unsigned,
status varchar(20) null,
currency varchar(10) null,
type varchar(20) null,
tax_amount decimal(26,8) null,
total_amount decimal(26,8) null,
customer_id bigint(20) unsigned null,
billing_email varchar(320) null,
date_created_gmt datetime null,
date_updated_gmt datetime null,
parent_order_id bigint(20) unsigned null,
payment_method varchar(100) null,
payment_method_title text null,
transaction_id varchar(100) null,
ip_address varchar(100) null,
user_agent text null,
customer_note text null,
PRIMARY KEY (id),
KEY status (status),
KEY date_created (date_created_gmt),
KEY customer_id_billing_email (customer_id, billing_email({$composite_customer_id_email_length})),
KEY billing_email (billing_email($max_index_length)),
KEY type_status_date (type, status, date_created_gmt),
KEY parent_order_id (parent_order_id),
KEY date_updated (date_updated_gmt)
) $collate;
CREATE TABLE $addresses_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NOT NULL,
address_type varchar(20) null,
first_name text null,
last_name text null,
company text null,
address_1 text null,
address_2 text null,
city text null,
state text null,
postcode text null,
country text null,
email varchar(320) null,
phone varchar(100) null,
KEY order_id (order_id),
UNIQUE KEY address_type_order_id (address_type, order_id),
KEY email (email($max_index_length)),
KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NULL,
created_via varchar(100) NULL,
woocommerce_version varchar(20) NULL,
prices_include_tax tinyint(1) NULL,
coupon_usages_are_counted tinyint(1) NULL,
download_permission_granted tinyint(1) NULL,
cart_hash varchar(100) NULL,
new_order_email_sent tinyint(1) NULL,
order_key varchar(100) NULL,
order_stock_reduced tinyint(1) NULL,
date_paid_gmt datetime NULL,
date_completed_gmt datetime NULL,
shipping_tax_amount decimal(26,8) NULL,
shipping_total_amount decimal(26,8) NULL,
discount_tax_amount decimal(26,8) NULL,
discount_total_amount decimal(26,8) NULL,
recorded_sales tinyint(1) NULL,
UNIQUE KEY order_id (order_id),
KEY order_key (order_key)
) $collate;
CREATE TABLE $meta_table (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned null,
meta_key varchar(255),
meta_value text null,
KEY meta_key_value (meta_key(100), meta_value($composite_meta_value_index_length)),
KEY order_id_meta_key_meta_value (order_id, meta_key(100), meta_value($composite_meta_value_index_length))
) $collate;
";
return $sql;
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
$raw_meta_data = $this->data_store_meta->read_meta( $object );
return $this->filter_raw_meta_data( $object, $raw_meta_data );
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing at least ->id).
*
* @return bool
*/
public function delete_meta( &$object, $meta ) {
if ( $this->should_backfill_post_record() && isset( $meta->id ) ) {
// Let's get the actual meta key before its deleted for backfilling. We cannot delete just by ID because meta IDs are different in HPOS and posts tables.
$db_meta = $this->data_store_meta->get_metadata_by_id( $meta->id );
if ( $db_meta ) {
$meta->key = $db_meta->meta_key;
$meta->value = $db_meta->meta_value;
}
}
$delete_meta = $this->data_store_meta->delete_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
self::$backfilling_order_ids[] = $object->get_id();
delete_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $delete_meta;
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->key and ->value).
*
* @return int|bool meta ID or false on failure
*/
public function add_meta( &$object, $meta ) {
$add_meta = $this->data_store_meta->add_meta( $object, $meta );
$meta->id = $add_meta;
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
add_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $add_meta;
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->id, ->key and ->value).
*
* @return bool The number of rows updated, or false on error.
*/
public function update_meta( &$object, $meta ) {
$update_meta = $this->data_store_meta->update_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
update_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $update_meta;
}
/**
* Perform after meta change operations, including updating the date_modified field, clearing caches and applying changes.
*
* @param WC_Abstract_Order $order Order object.
* @param \WC_Meta_Data $meta Metadata object.
*
* @return bool True if changes were applied, false otherwise.
*/
protected function after_meta_change( &$order, $meta ) {
method_exists( $meta, 'apply_changes' ) && $meta->apply_changes();
// Prevent this happening multiple time in same request.
if ( $this->should_save_after_meta_change( $order ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
$order->save();
return true;
} else {
$order_cache = wc_get_container()->get( OrderCache::class );
$order_cache->remove( $order->get_id() );
}
return false;
}
/**
* Helper function to check whether the modified date needs to be updated after a meta save.
*
* This method prevents order->save() call multiple times in the same request after any meta update by checking if:
* 1. Order modified date is already the current date, no updates needed in this case.
* 2. If there are changes already queued for order object, then we don't need to update the modified date as it will be updated ina subsequent save() call.
*
* @param WC_Order $order Order object.
*
* @return bool Whether the modified date needs to be updated.
*/
private function should_save_after_meta_change( $order ) {
$current_date_time = new \WC_DateTime( current_time( 'mysql', 1 ), new \DateTimeZone( 'GMT' ) );
return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() );
}
}
Orders/OrdersTableDataStoreMeta.php 0000644 00000001336 15154263117 0013347 0 ustar 00 <?php
/**
* OrdersTableDataStoreMeta class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
/**
* Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
*/
class OrdersTableDataStoreMeta extends CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
protected function get_table_name() {
return OrdersTableDataStore::get_meta_table_name();
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'order_id';
}
}
Orders/OrdersTableFieldQuery.php 0000644 00000020740 15154263117 0012723 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
* complex queries against order fields in the database.
*
* @internal
*/
class OrdersTableFieldQuery {
/**
* List of valid SQL operators to use as field_query 'compare' values.
*
* @var array
*/
private const VALID_COMPARISON_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* The original query object.
*
* @var OrdersTableQuery
*/
private $query = null;
/**
* Determines whether the field query should produce no results due to an invalid argument.
*
* @var boolean
*/
private $force_no_results = false;
/**
* Holds a sanitized version of the `field_query`.
*
* @var array
*/
private $queries = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$field_query = $q->get( 'field_query' );
if ( ! $field_query || ! is_array( $field_query ) ) {
return;
}
$this->query = $q;
$this->queries = $this->sanitize_query( $field_query );
$this->where = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
}
/**
* Sanitizes the field_query argument.
*
* @param array $q A field_query array.
* @return array A sanitized field query array.
* @throws \Exception When field table info is missing.
*/
private function sanitize_query( array $q ) {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
continue;
}
// Sanitize 'compare'.
$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';
if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
$arg['compare'] = 'IN';
}
// Sanitize 'cast'.
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$field_info = $this->query->get_field_mapping_info( $arg['field'] );
if ( ! $field_info ) {
$this->force_no_results = true;
continue;
}
$arg = array_merge( $arg, $field_info );
$sanitized[ $key ] = $arg;
} else {
$sanitized_arg = $this->sanitize_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $q A field query.
* @return string An SQL WHERE statement.
*/
private function process( array $q ) {
$where = '';
if ( empty( $q ) ) {
return $where;
}
if ( $this->is_atomic( $q ) ) {
$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
$where = $this->generate_where_for_clause( $q );
} else {
$relation = $q['relation'];
unset( $q['relation'] );
foreach ( $q as $query ) {
$chunks[] = $this->process( $query );
}
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = '(' . implode( " {$relation} ", $chunks ) . ')';
}
}
return $where;
}
/**
* Checks whether a given field_query clause is atomic or not (i.e. not nested).
*
* @param array $q The field_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( $q ) {
return isset( $q['field'] );
}
/**
* Finds a common table alias that the field_query clause can use, or creates one.
*
* @param array $q An atomic field_query clause.
* @return string A table alias for use in an SQL JOIN clause.
* @throws \Exception When table info for clause is missing.
*/
private function find_or_create_table_alias_for_clause( $q ) {
global $wpdb;
if ( ! empty( $q['alias'] ) ) {
return $q['alias'];
}
if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
}
$join = '';
if ( isset( $q['mapping_id'] ) ) {
// Re-use JOINs and aliases from OrdersTableQuery for core tables.
$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
$join = $this->query->get_core_mapping_join( $q['mapping_id'] );
} else {
$alias = $q['table'];
$join = '';
}
if ( in_array( $alias, $this->table_aliases, true ) ) {
return $alias;
}
$this->table_aliases[] = $alias;
if ( $join ) {
$this->join[ $alias ] = $join;
}
return $alias;
}
/**
* Returns the correct type for a given clause 'type'.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( $type ) {
$clause_type = strtoupper( $type );
if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $clause_type ) {
$clause_type = 'SIGNED';
}
return $clause_type;
}
/**
* Generates an SQL WHERE clause for a given field_query atomic clause.
*
* @param array $clause An atomic field_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause( $clause ): string {
global $wpdb;
$clause_value = $clause['value'] ?? '';
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $clause_value ) ) {
$clause_value = preg_split( '/[,\s]+/', $clause_value );
}
} elseif ( is_string( $clause_value ) ) {
$clause_value = trim( $clause_value );
}
$clause_compare = $clause['compare'];
switch ( $clause_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
break;
case 'EXISTS':
// EXISTS with a value is interpreted as '='.
if ( $clause_value ) {
$clause_compare = '=';
$where = $wpdb->prepare( '%s', $clause_value );
} else {
$clause_compare = 'IS NOT';
$where = 'NULL';
}
break;
case 'NOT EXISTS':
// 'value' is ignored for NOT EXISTS.
$clause_compare = 'IS';
$where = 'NULL';
break;
default:
$where = $wpdb->prepare( '%s', $clause_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
} else {
return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
}
}
return '';
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses() {
return array(
'join' => $this->join,
'where' => $this->where ? array( $this->where ) : array(),
);
}
}
Orders/OrdersTableMetaQuery.php 0000644 00000045036 15154263117 0012573 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Class used to implement meta queries for the orders table datastore via {@see OrdersTableQuery}.
* Heavily inspired by WordPress' own `WP_Meta_Query` for backwards compatibility reasons.
*
* Parts of the implementation have been adapted from {@link https://core.trac.wordpress.org/browser/tags/6.0.1/src/wp-includes/class-wp-meta-query.php}.
*/
class OrdersTableMetaQuery {
/**
* List of non-numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NON_NUMERIC_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
);
/**
* List of numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NUMERIC_OPERATORS = array(
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* Prefix used when generating aliases for the metadata table.
*
* @var string
*/
private const ALIAS_PREFIX = 'meta';
/**
* Name of the main orders table.
*
* @var string
*/
private $meta_table = '';
/**
* Name of the metadata table.
*
* @var string
*/
private $orders_table = '';
/**
* Sanitized `meta_query`.
*
* @var array
*/
private $queries = array();
/**
* Flat list of clauses by name.
*
* @var array
*/
private $flattened_clauses = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the meta query. Used to optimize JOINs when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$meta_query = $q->get( 'meta_query' );
if ( ! $meta_query ) {
return;
}
$this->queries = $this->sanitize_meta_query( $meta_query );
$this->meta_table = $q->get_table_name( 'meta' );
$this->orders_table = $q->get_table_name( 'orders' );
$this->build_query();
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => $this->sanitize_join( $this->join ),
'where' => $this->flatten_where_clauses( $this->where ),
);
}
/**
* Returns a list of names (corresponding to meta_query clauses) that can be used as an 'orderby' arg.
*
* @since 7.4
*
* @return array
*/
public function get_orderby_keys(): array {
if ( ! $this->flattened_clauses ) {
return array();
}
$keys = array();
$keys[] = 'meta_value';
$keys[] = 'meta_value_num';
$first_clause = reset( $this->flattened_clauses );
if ( $first_clause && ! empty( $first_clause['key'] ) ) {
$keys[] = $first_clause['key'];
}
$keys = array_merge(
$keys,
array_keys( $this->flattened_clauses )
);
return $keys;
}
/**
* Returns an SQL fragment for the given meta_query key that can be used in an ORDER BY clause.
* Call {@see 'get_orderby_keys'} to obtain a list of valid keys.
*
* @since 7.4
*
* @param string $key The key name.
* @return string
*
* @throws \Exception When an invalid key is passed.
*/
public function get_orderby_clause_for_key( string $key ): string {
$clause = false;
if ( isset( $this->flattened_clauses[ $key ] ) ) {
$clause = $this->flattened_clauses[ $key ];
} else {
$first_clause = reset( $this->flattened_clauses );
if ( $first_clause && ! empty( $first_clause['key'] ) ) {
if ( 'meta_value_num' === $key ) {
return "{$first_clause['alias']}.meta_value+0";
}
if ( 'meta_value' === $key || $first_clause['key'] === $key ) {
$clause = $first_clause;
}
}
}
if ( ! $clause ) {
// translators: %s is a meta_query key.
throw new \Exception( sprintf( __( 'Invalid meta_query clause key: %s.', 'woocommerce' ), $key ) );
}
return "CAST({$clause['alias']}.meta_value AS {$clause['cast']})";
}
/**
* Checks whether a given meta_query clause is atomic or not (i.e. not nested).
*
* @param array $arg The meta_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( array $arg ): bool {
return isset( $arg['key'] ) || isset( $arg['value'] );
}
/**
* Sanitizes the meta_query argument.
*
* @param array $q A meta_query array.
* @return array A sanitized meta query array.
*/
private function sanitize_meta_query( array $q ): array {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
unset( $arg['value'] );
}
$arg['compare'] = isset( $arg['compare'] ) ? strtoupper( $arg['compare'] ) : ( isset( $arg['value'] ) && is_array( $arg['value'] ) ? 'IN' : '=' );
$arg['compare_key'] = isset( $arg['compare_key'] ) ? strtoupper( $arg['compare_key'] ) : ( isset( $arg['key'] ) && is_array( $arg['key'] ) ? 'IN' : '=' );
if ( ! in_array( $arg['compare'], self::NON_NUMERIC_OPERATORS, true ) && ! in_array( $arg['compare'], self::NUMERIC_OPERATORS, true ) ) {
$arg['compare'] = '=';
}
if ( ! in_array( $arg['compare_key'], self::NON_NUMERIC_OPERATORS, true ) ) {
$arg['compare_key'] = '=';
}
$sanitized[ $key ] = $arg;
$sanitized[ $key ]['index'] = $key;
} else {
$sanitized_arg = $this->sanitize_meta_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Returns the correct type for a given meta type.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( string $type = '' ): string {
$meta_type = strtoupper( $type );
if ( ! $meta_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $meta_type ) {
$meta_type = 'SIGNED';
}
return $meta_type;
}
/**
* Makes sure a JOIN array does not have duplicates.
*
* @param array $join A JOIN array.
* @return array A sanitized JOIN array.
*/
private function sanitize_join( array $join ): array {
return array_filter( array_unique( array_map( 'trim', $join ) ) );
}
/**
* Flattens a nested WHERE array.
*
* @param array $where A possibly nested WHERE array with AND/OR operators.
* @return string An SQL WHERE clause.
*/
private function flatten_where_clauses( $where ): string {
if ( is_string( $where ) ) {
return trim( $where );
}
$chunks = array();
$operator = $this->sanitize_relation( $where['operator'] ?? '' );
foreach ( $where as $key => $w ) {
if ( 'operator' === $key ) {
continue;
}
$flattened = $this->flatten_where_clauses( $w );
if ( $flattened ) {
$chunks[] = $flattened;
}
}
if ( $chunks ) {
return '(' . implode( " {$operator} ", $chunks ) . ')';
} else {
return '';
}
}
/**
* Builds all the required internal bits for this meta query.
*
* @return void
*/
private function build_query(): void {
if ( ! $this->queries ) {
return;
}
$queries = $this->queries;
$sql_where = $this->process( $queries );
$this->where = $sql_where;
}
/**
* Processes meta_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $arg A meta query.
* @param null|array $parent The parent of the element being processed.
* @return array A nested array of WHERE conditions.
*/
private function process( array &$arg, &$parent = null ): array {
$where = array();
if ( $this->is_atomic( $arg ) ) {
$arg['alias'] = $this->find_or_create_table_alias_for_clause( $arg, $parent );
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$where = array_filter(
array(
$this->generate_where_for_clause_key( $arg ),
$this->generate_where_for_clause_value( $arg ),
)
);
// Store clauses by their key for ORDER BY purposes.
$flat_clause_key = is_int( $arg['index'] ) ? $arg['alias'] : $arg['index'];
$unique_flat_key = $flat_clause_key;
$i = 1;
while ( isset( $this->flattened_clauses[ $unique_flat_key ] ) ) {
$unique_flat_key = $flat_clause_key . '-' . $i;
$i++;
}
$this->flattened_clauses[ $unique_flat_key ] =& $arg;
} else {
// Nested.
$relation = $arg['relation'];
unset( $arg['relation'] );
foreach ( $arg as $index => &$clause ) {
$chunks[] = $this->process( $clause, $arg );
}
// Merge chunks of the form OR(m) with the surrounding clause.
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = array_merge(
array(
'operator' => $relation,
),
$chunks
);
}
}
return $where;
}
/**
* Generates a JOIN clause to handle an atomic meta_query clause.
*
* @param array $clause An atomic meta_query clause.
* @param string $alias Metadata table alias to use.
* @return string An SQL JOIN clause.
*/
private function generate_join_for_clause( array $clause, string $alias ): string {
global $wpdb;
if ( 'NOT EXISTS' === $clause['compare'] ) {
if ( 'LIKE' === $clause['compare_key'] ) {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key LIKE %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'%' . $wpdb->esc_like( $clause['key'] ) . '%'
);
} else {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$clause['key']
);
}
}
return "INNER JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id )";
}
/**
* Finds a common table alias that the meta_query clause can use, or creates one.
*
* @param array $clause An atomic meta_query clause.
* @param array $parent_query The parent query this clause is in.
* @return string A table alias for use in an SQL JOIN clause.
*/
private function find_or_create_table_alias_for_clause( array $clause, array $parent_query ): string {
if ( ! empty( $clause['alias'] ) ) {
return $clause['alias'];
}
$alias = false;
$siblings = array_filter(
$parent_query,
array( __CLASS__, 'is_atomic' )
);
foreach ( $siblings as $sibling ) {
if ( empty( $sibling['alias'] ) ) {
continue;
}
if ( $this->is_operator_compatible_with_shared_join( $clause, $sibling, $parent_query['relation'] ?? 'AND' ) ) {
$alias = $sibling['alias'];
break;
}
}
if ( ! $alias ) {
$alias = self::ALIAS_PREFIX . count( $this->table_aliases );
$this->join[] = $this->generate_join_for_clause( $clause, $alias );
$this->table_aliases[] = $alias;
}
return $alias;
}
/**
* Checks whether two meta_query clauses can share a JOIN.
*
* @param array $clause An atomic meta_query clause.
* @param array $sibling An atomic meta_query clause.
* @param string $relation The relation involving both clauses.
* @return boolean TRUE if the clauses can share a table alias, FALSE otherwise.
*/
private function is_operator_compatible_with_shared_join( array $clause, array $sibling, string $relation = 'AND' ): bool {
if ( ! $this->is_atomic( $clause ) || ! $this->is_atomic( $sibling ) ) {
return false;
}
$valid_operators = array();
if ( 'OR' === $relation ) {
$valid_operators = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
} elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
$valid_operators = array( '!=', 'NOT IN', 'NOT LIKE' );
}
return in_array( strtoupper( $clause['compare'] ), $valid_operators, true ) && in_array( strtoupper( $sibling['compare'] ), $valid_operators, true );
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta key.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_key( array $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'key', $clause ) ) {
return '';
}
if ( 'NOT EXISTS' === $clause['compare'] ) {
return "{$clause['alias']}.order_id IS NULL";
}
$alias = $clause['alias'];
if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
$i = count( $this->table_aliases );
$subquery_alias = self::ALIAS_PREFIX . $i;
$this->table_aliases[] = $subquery_alias;
$meta_compare_string_start = 'NOT EXISTS (';
$meta_compare_string_start .= "SELECT 1 FROM {$this->meta_table} {$subquery_alias} ";
$meta_compare_string_start .= "WHERE {$subquery_alias}.order_id = {$alias}.order_id ";
$meta_compare_string_end = 'LIMIT 1';
$meta_compare_string_end .= ')';
}
switch ( $clause['compare_key'] ) {
case '=':
case 'EXISTS':
$where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'LIKE':
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'IN':
$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')';
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'RLIKE':
case 'REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$where = $wpdb->prepare( "$alias.meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case '!=':
case 'NOT EXISTS':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key = %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT LIKE':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key LIKE %s " . $meta_compare_string_end;
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT IN':
$array_subclause = '(' . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ') ';
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key IN " . $array_subclause . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key REGEXP $cast %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
default:
$where = '';
break;
}
return $where;
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta value.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_value( $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'value', $clause ) ) {
return '';
}
$meta_value = $clause['value'];
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $meta_value ) ) {
$meta_value = preg_split( '/[,\s]+/', $meta_value );
}
} elseif ( is_string( $meta_value ) ) {
$meta_value = trim( $meta_value );
}
$meta_compare = $clause['compare'];
switch ( $meta_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $meta_value[0], $meta_value[1] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $meta_value ) . '%' );
break;
// EXISTS with a value is interpreted as '='.
case 'EXISTS':
$meta_compare = '=';
$where = $wpdb->prepare( '%s', $meta_value );
break;
// 'value' is ignored for NOT EXISTS.
case 'NOT EXISTS':
$where = '';
break;
default:
$where = $wpdb->prepare( '%s', $meta_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "{$clause['alias']}.meta_value {$meta_compare} {$where}";
} else {
return "CAST({$clause['alias']}.meta_value AS {$clause['cast']}) {$meta_compare} {$where}";
}
}
}
}
Orders/OrdersTableQuery.php 0000644 00000127575 15154263117 0011775 0 ustar 00 <?php
// phpcs:disable Generic.Commenting.Todo.TaskFound
/**
* OrdersTableQuery class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class provides a `WP_Query`-like interface to custom order tables.
*
* @property-read int $found_orders Number of found orders.
* @property-read int $found_posts Alias of the `$found_orders` property.
* @property-read int $max_num_pages Max number of pages matching the current query.
* @property-read array $orders Order objects, or order IDs.
* @property-read array $posts Alias of the $orders property.
*/
class OrdersTableQuery {
/**
* Values to ignore when parsing query arguments.
*/
public const SKIPPED_VALUES = array( '', array(), null );
/**
* Regex used to catch "shorthand" comparisons in date-related query args.
*/
public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';
/**
* Highest possible unsigned bigint value (unsigned bigints being the type of the `id` column).
*
* This is deliberately held as a string, rather than a numeric type, for inclusion within queries.
*/
private const MYSQL_MAX_UNSIGNED_BIGINT = '18446744073709551615';
/**
* Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
*
* @var array
*/
private $tables = array();
/**
* Column mappings for all COT tables.
*
* @var array
*/
private $mappings = array();
/**
* Query vars after processing and sanitization.
*
* @var array
*/
private $args = array();
/**
* Columns to be selected in the SELECT clause.
*
* @var array
*/
private $fields = array();
/**
* Array of table aliases and conditions used to compute the JOIN clause of the query.
*
* @var array
*/
private $join = array();
/**
* Array of fields and conditions used to compute the WHERE clause of the query.
*
* @var array
*/
private $where = array();
/**
* Field to be used in the GROUP BY clause of the query.
*
* @var array
*/
private $groupby = array();
/**
* Array of fields used to compute the ORDER BY clause of the query.
*
* @var array
*/
private $orderby = array();
/**
* Limits used to compute the LIMIT clause of the query.
*
* @var array
*/
private $limits = array();
/**
* Results (order IDs) for the current query.
*
* @var array
*/
private $orders = array();
/**
* Final SQL query to run after processing of args.
*
* @var string
*/
private $sql = '';
/**
* Final SQL query to count results after processing of args.
*
* @var string
*/
private $count_sql = '';
/**
* The number of pages (when pagination is enabled).
*
* @var int
*/
private $max_num_pages = 0;
/**
* The number of orders found.
*
* @var int
*/
private $found_orders = 0;
/**
* Field query parser.
*
* @var OrdersTableFieldQuery
*/
private $field_query = null;
/**
* Meta query parser.
*
* @var OrdersTableMetaQuery
*/
private $meta_query = null;
/**
* Search query parser.
*
* @var OrdersTableSearchQuery?
*/
private $search_query = null;
/**
* Date query parser.
*
* @var WP_Date_Query
*/
private $date_query = null;
/**
* Instance of the OrdersTableDataStore class.
*
* @var OrdersTableDataStore
*/
private $order_datastore = null;
/**
* Whether to run filters to modify the query or not.
*
* @var boolean
*/
private $suppress_filters = false;
/**
* Sets up and runs the query after processing arguments.
*
* @param array $args Array of query vars.
*/
public function __construct( $args = array() ) {
// Note that ideally we would inject this dependency via constructor, but that's not possible since this class needs to be backward compatible with WC_Order_Query class.
$this->order_datastore = wc_get_container()->get( OrdersTableDataStore::class );
$this->tables = $this->order_datastore::get_all_table_names_with_id();
$this->mappings = $this->order_datastore->get_all_order_column_mappings();
$this->suppress_filters = array_key_exists( 'suppress_filters', $args ) ? (bool) $args['suppress_filters'] : false;
unset( $args['suppress_filters'] );
$this->args = $args;
// TODO: args to be implemented.
unset( $this->args['customer_note'], $this->args['name'] );
$this->build_query();
if ( ! $this->maybe_override_query() ) {
$this->run_query();
}
}
/**
* Lets the `woocommerce_hpos_pre_query` filter override the query.
*
* @return boolean Whether the query was overridden or not.
*/
private function maybe_override_query(): bool {
/**
* Filters the orders array before the query takes place.
*
* Return a non-null value to bypass the HPOS default order queries.
*
* If the query includes limits via the `limit`, `page`, or `offset` arguments, we
* encourage the `found_orders` and `max_num_pages` properties to also be set.
*
* @since 8.2.0
*
* @param array|null $order_data {
* An array of order data.
* @type int[] $orders Return an array of order IDs data to short-circuit the HPOS query,
* or null to allow HPOS to run its normal query.
* @type int $found_orders The number of orders found.
* @type int $max_num_pages The number of pages.
* }
* @param OrdersTableQuery $query The OrdersTableQuery instance.
* @param string $sql The OrdersTableQuery instance.
*/
$pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
return false;
}
// If the filter set the orders, make sure the others values are set as well and skip running the query.
list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;
if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
$this->found_orders = count( $this->orders );
}
if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
$this->args['limit'] = 10;
}
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
}
return true;
}
/**
* Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
*
* @return void
*/
private function maybe_remap_args(): void {
$mapping = array(
// WP_Query legacy.
'post_date' => 'date_created',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_updated',
'post_modified_gmt' => 'date_updated_gmt',
'post_status' => 'status',
'_date_completed' => 'date_completed',
'_date_paid' => 'date_paid',
'paged' => 'page',
'post_parent' => 'parent_order_id',
'post_parent__in' => 'parent_order_id',
'post_parent__not_in' => 'parent_exclude',
'post__not_in' => 'exclude',
'posts_per_page' => 'limit',
'p' => 'id',
'post__in' => 'id',
'post_type' => 'type',
'fields' => 'return',
'customer_user' => 'customer_id',
'order_currency' => 'currency',
'order_version' => 'woocommerce_version',
'cart_discount' => 'discount_total_amount',
'cart_discount_tax' => 'discount_tax_amount',
'order_shipping' => 'shipping_total_amount',
'order_shipping_tax' => 'shipping_tax_amount',
'order_tax' => 'tax_amount',
// Translate from WC_Order_Query to table structure.
'version' => 'woocommerce_version',
'date_modified' => 'date_updated',
'date_modified_gmt' => 'date_updated_gmt',
'discount_total' => 'discount_total_amount',
'discount_tax' => 'discount_tax_amount',
'shipping_total' => 'shipping_total_amount',
'shipping_tax' => 'shipping_tax_amount',
'cart_tax' => 'tax_amount',
'total' => 'total_amount',
'customer_ip_address' => 'ip_address',
'customer_user_agent' => 'user_agent',
'parent' => 'parent_order_id',
);
foreach ( $mapping as $query_key => $table_field ) {
if ( isset( $this->args[ $query_key ] ) && '' !== $this->args[ $query_key ] ) {
$this->args[ $table_field ] = $this->args[ $query_key ];
unset( $this->args[ $query_key ] );
}
}
// meta_query.
$this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$shortcut_meta_query = array();
foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
if ( $this->arg_isset( "meta_{$key}" ) ) {
$shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
}
}
if ( ! empty( $shortcut_meta_query ) ) {
if ( ! empty( $this->args['meta_query'] ) ) {
$this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'AND',
$shortcut_meta_query,
$this->args['meta_query'],
);
} else {
$this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
}
}
/**
* Generates a `WP_Date_Query` compatible query from a given date.
* YYYY-MM-DD queries have 'day' precision for backwards compatibility.
*
* @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
private function date_to_date_query_arg( $date ): array {
$result = array(
'year' => '',
'month' => '',
'day' => '',
);
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
$precision = 'second';
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
$date = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
$precision = 'day';
}
$result['year'] = $date->date( 'Y' );
$result['month'] = $date->date( 'm' );
$result['day'] = $date->date( 'd' );
if ( 'second' === $precision ) {
$result['hour'] = $date->date( 'H' );
$result['minute'] = $date->date( 'i' );
$result['second'] = $date->date( 's' );
}
return $result;
}
/**
* Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
*
* @param array $dates_raw Array of dates (in local time) to use in combination with the operator.
* @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
* @return array Partial date query arg with relevant dates now UTC-based.
*
* @since 8.2.0
*/
private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
$result = array();
// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
foreach ( $dates_raw as &$raw_date ) {
$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
}
$date1 = end( $dates_raw );
switch ( $operator ) {
case '>':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => true,
);
break;
case '>=':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
);
break;
case '=':
$result = array(
'relation' => 'AND',
array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
),
array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
)
);
break;
case '<=':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
);
break;
case '<':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => false,
);
break;
case '...':
$result = array(
'relation' => 'AND',
$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
);
break;
}
if ( ! $result ) {
throw new \Exception( 'Please specify a valid date shorthand operator.' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
* @return void
* @throws \Exception When date args are invalid.
*/
private function process_date_args(): void {
if ( $this->arg_isset( 'date_query' ) ) {
// Process already passed date queries args.
$this->args['date_query'] = $this->map_gmt_and_post_keys_to_hpos_keys( $this->args['date_query'] );
}
$valid_operators = array( '>', '>=', '=', '<=', '<', '...' );
$date_queries = array();
$local_to_gmt_date_keys = array(
'date_created' => 'date_created_gmt',
'date_updated' => 'date_updated_gmt',
'date_paid' => 'date_paid_gmt',
'date_completed' => 'date_completed_gmt',
);
$gmt_date_keys = array_values( $local_to_gmt_date_keys );
$local_date_keys = array_keys( $local_to_gmt_date_keys );
$valid_date_keys = array_merge( $gmt_date_keys, $local_date_keys );
$date_keys = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );
foreach ( $date_keys as $date_key ) {
$is_local = in_array( $date_key, $local_date_keys, true );
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates_raw = array();
$dates = array();
if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
if ( ! empty( $matches[1] ) ) {
$dates_raw[] = $matches[1];
}
$dates_raw[] = $matches[3];
} else {
$dates_raw[] = $date_value;
}
if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
if ( $is_local ) {
$date_key = $local_to_gmt_date_keys[ $date_key ];
if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
$date_queries[] = array_merge(
array(
'column' => $date_key,
),
$this->local_time_to_gmt_date_query( $dates_raw, $operator )
);
continue;
}
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
$operator_to_keys[] = 'after';
}
if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
$operator_to_keys[] = 'before';
}
$dates = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
$date_queries[] = array_merge(
array(
'column' => $date_key,
'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
),
'=' === $operator
? end( $dates )
: array_combine( $operator_to_keys, $dates )
);
}
// Add top-level date parameters to the date_query.
$tl_query = array();
foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
if ( $this->arg_isset( $tl_key ) ) {
$tl_query[ $tl_key ] = $this->args[ $tl_key ];
unset( $this->args[ $tl_key ] );
}
}
if ( $tl_query ) {
$tl_query['column'] = 'date_created_gmt';
$date_queries[] = $tl_query;
}
if ( $date_queries ) {
if ( ! $this->arg_isset( 'date_query' ) ) {
$this->args['date_query'] = array();
}
$this->args['date_query'] = array_merge(
array( 'relation' => 'AND' ),
$date_queries,
$this->args['date_query']
);
}
$this->process_date_query_columns();
}
/**
* Helper function to map posts and gmt based keys to HPOS keys.
*
* @param array $query Date query argument.
*
* @return array|mixed Date query argument with modified keys.
*/
private function map_gmt_and_post_keys_to_hpos_keys( $query ) {
if ( ! is_array( $query ) ) {
return $query;
}
$post_to_hpos_mappings = array(
'post_date' => 'date_created',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_updated',
'post_modified_gmt' => 'date_updated_gmt',
'_date_completed' => 'date_completed',
'_date_paid' => 'date_paid',
'date_modified' => 'date_updated',
'date_modified_gmt' => 'date_updated_gmt',
);
$local_to_gmt_date_keys = array(
'date_created' => 'date_created_gmt',
'date_updated' => 'date_updated_gmt',
'date_paid' => 'date_paid_gmt',
'date_completed' => 'date_completed_gmt',
);
array_walk(
$query,
function ( &$sub_query ) {
$sub_query = $this->map_gmt_and_post_keys_to_hpos_keys( $sub_query );
}
);
if ( ! isset( $query['column'] ) ) {
return $query;
}
if ( isset( $post_to_hpos_mappings[ $query['column'] ] ) ) {
$query['column'] = $post_to_hpos_mappings[ $query['column'] ];
}
// Convert any local dates to GMT.
if ( isset( $local_to_gmt_date_keys[ $query['column'] ] ) ) {
$query['column'] = $local_to_gmt_date_keys[ $query['column'] ];
$op = isset( $query['after'] ) ? 'after' : 'before';
$date_value_local = $query[ $op ];
$date_value_gmt = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt );
}
return $query;
}
/**
* Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
*
* @return void
*/
private function process_date_query_columns() {
global $wpdb;
$legacy_columns = array(
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
);
$table_mapping = array(
'date_created_gmt' => $this->tables['orders'],
'date_updated_gmt' => $this->tables['orders'],
'date_paid_gmt' => $this->tables['operational_data'],
'date_completed_gmt' => $this->tables['operational_data'],
);
if ( empty( $this->args['date_query'] ) ) {
return;
}
array_walk_recursive(
$this->args['date_query'],
function( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
if ( 'column' !== $key ) {
return;
}
// Translate legacy columns from wp_posts if necessary.
$value =
( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
? $legacy_columns[ $value ]
: $value;
$table = $table_mapping[ $value ] ?? null;
if ( ! $table ) {
return;
}
$value = "{$table}.{$value}";
if ( $table !== $this->tables['orders'] ) {
$this->join( $table, '', '', 'inner', true );
}
}
);
}
/**
* Sanitizes the 'status' query var.
*
* @return void
*/
private function sanitize_status(): void {
// Sanitize status.
$valid_statuses = array_keys( wc_get_order_statuses() );
if ( empty( $this->args['status'] ) || 'any' === $this->args['status'] ) {
$this->args['status'] = $valid_statuses;
} elseif ( 'all' === $this->args['status'] ) {
$this->args['status'] = array();
} else {
$this->args['status'] = is_array( $this->args['status'] ) ? $this->args['status'] : array( $this->args['status'] );
foreach ( $this->args['status'] as &$status ) {
$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
}
$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
}
}
/**
* Parses and sanitizes the 'orderby' query var.
*
* @return void
*/
private function sanitize_order_orderby(): void {
// Allowed keys.
// TODO: rand, meta keys, etc.
$allowed_keys = array( 'ID', 'id', 'type', 'date', 'modified', 'parent' );
// Translate $orderby to a valid field.
$mapping = array(
'ID' => "{$this->tables['orders']}.id",
'id' => "{$this->tables['orders']}.id",
'type' => "{$this->tables['orders']}.type",
'date' => "{$this->tables['orders']}.date_created_gmt",
'date_created' => "{$this->tables['orders']}.date_created_gmt",
'modified' => "{$this->tables['orders']}.date_updated_gmt",
'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
'parent' => "{$this->tables['orders']}.parent_order_id",
'total' => "{$this->tables['orders']}.total_amount",
'order_total' => "{$this->tables['orders']}.total_amount",
);
$order = $this->args['order'] ?? '';
$orderby = $this->args['orderby'] ?? '';
if ( 'none' === $orderby ) {
return;
}
// No need to sanitize, will be processed in calling function.
if ( 'include' === $orderby || 'post__in' === $orderby ) {
return;
}
if ( is_string( $orderby ) ) {
$orderby_fields = array_map( 'trim', explode( ' ', $orderby ) );
$orderby = array();
foreach ( $orderby_fields as $field ) {
$orderby[ $field ] = $order;
}
}
$allowed_orderby = array_merge(
array_keys( $mapping ),
array_values( $mapping ),
$this->meta_query ? $this->meta_query->get_orderby_keys() : array()
);
$this->args['orderby'] = array();
foreach ( $orderby as $order_key => $order ) {
if ( ! in_array( $order_key, $allowed_orderby, true ) ) {
continue;
}
if ( isset( $mapping[ $order_key ] ) ) {
$order_key = $mapping[ $order_key ];
}
$this->args['orderby'][ $order_key ] = $this->sanitize_order( $order );
}
}
/**
* Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
*
* @param string $order The unsanitized order.
* @return string The sanitized order.
*/
private function sanitize_order( string $order ): string {
$order = strtoupper( $order );
return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
}
/**
* Builds the final SQL query to be run.
*
* @return void
*/
private function build_query(): void {
$this->maybe_remap_args();
// Field queries.
if ( ! empty( $this->args['field_query'] ) ) {
$this->field_query = new OrdersTableFieldQuery( $this );
$sql = $this->field_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Build query.
$this->process_date_args();
$this->process_orders_table_query_args();
$this->process_operational_data_table_query_args();
$this->process_addresses_table_query_args();
// Search queries.
if ( ! empty( $this->args['s'] ) ) {
$this->search_query = new OrdersTableSearchQuery( $this );
$sql = $this->search_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Meta queries.
if ( ! empty( $this->args['meta_query'] ) ) {
$this->meta_query = new OrdersTableMetaQuery( $this );
$sql = $this->meta_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;
}
// Date queries.
if ( ! empty( $this->args['date_query'] ) ) {
$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
$this->where[] = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
}
$this->process_orderby();
$this->process_limit();
$orders_table = $this->tables['orders'];
// Group by is a faster substitute for DISTINCT, as long as we are only selecting IDs. MySQL don't like it when we join tables and use DISTINCT.
$this->groupby[] = "{$this->tables['orders']}.id";
$this->fields = "{$orders_table}.id";
$fields = $this->fields;
// JOIN.
$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );
// WHERE.
$where = '1=1';
foreach ( $this->where as $_where ) {
$where .= " AND ({$_where})";
}
// ORDER BY.
$orderby = $this->orderby ? implode( ', ', $this->orderby ) : '';
// LIMITS.
$limits = '';
if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
list( $offset, $row_count ) = $this->limits;
$row_count = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
$limits = 'LIMIT ' . (int) $offset . ', ' . $row_count;
}
// GROUP BY.
$groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';
$pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );
if ( ! $this->suppress_filters ) {
/**
* Filters all query clauses at once.
* Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
*
* @since 7.9.0
*
* @param string[] $clauses {
* Associative array of the clauses for the query.
*
* @type string $fields The SELECT clause of the query.
* @type string $join The JOIN clause of the query.
* @type string $where The WHERE clause of the query.
* @type string $groupby The GROUP BY clause of the query.
* @type string $orderby The ORDER BY clause of the query.
* @type string $limits The LIMIT clause of the query.
* }
* @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
* @param array $args Query args.
*/
$clauses = (array) apply_filters_ref_array( 'woocommerce_orders_table_query_clauses', array( $pieces, &$this, $this->args ) );
$fields = $clauses['fields'] ?? '';
$join = $clauses['join'] ?? '';
$where = $clauses['where'] ?? '';
$groupby = $clauses['groupby'] ?? '';
$orderby = $clauses['orderby'] ?? '';
$limits = $clauses['limits'] ?? '';
}
$groupby = $groupby ? ( 'GROUP BY ' . $groupby ) : '';
$orderby = $orderby ? ( 'ORDER BY ' . $orderby ) : '';
$this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
if ( ! $this->suppress_filters ) {
/**
* Filters the completed SQL query.
*
* @since 7.9.0
*
* @param string $sql The complete SQL query.
* @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
* @param array $args Query args.
*/
$this->sql = apply_filters_ref_array( 'woocommerce_orders_table_query_sql', array( $this->sql, &$this, $this->args ) );
}
$this->build_count_query( $fields, $join, $where, $groupby );
}
/**
* Build SQL query for counting total number of results.
*
* @param string $fields Prepared fields for SELECT clause.
* @param string $join Prepared JOIN clause.
* @param string $where Prepared WHERE clause.
* @param string $groupby Prepared GROUP BY clause.
*/
private function build_count_query( $fields, $join, $where, $groupby ) {
if ( ! isset( $this->sql ) || '' === $this->sql ) {
wc_doing_it_wrong( __FUNCTION__, 'Count query can only be build after main query is built.', '7.3.0' );
}
$orders_table = $this->tables['orders'];
$this->count_sql = "SELECT COUNT(DISTINCT $fields) FROM $orders_table $join WHERE $where";
}
/**
* Returns the table alias for a given table mapping.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string Table alias.
*
* @since 7.0.0
*/
public function get_core_mapping_alias( string $mapping_id ): string {
return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
? $mapping_id
: $this->tables[ $mapping_id ];
}
/**
* Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string The JOIN clause.
*
* @since 7.0.0
*/
public function get_core_mapping_join( string $mapping_id ): string {
global $wpdb;
if ( 'orders' === $mapping_id ) {
return '';
}
$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );
$alias = $this->get_core_mapping_alias( $mapping_id );
$table = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
$join = '';
$join_on = '';
$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );
if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
if ( $is_address_mapping ) {
$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
}
/**
* JOINs the main orders table with another table.
*
* @param string $table Table name (including prefix).
* @param string $alias Table alias to use. Defaults to $table.
* @param string $on ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
* @param string $join_type JOIN type: LEFT, RIGHT or INNER.
* @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
* @return void
* @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
*/
private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
$alias = empty( $alias ) ? $table : $alias;
$join_type = strtoupper( trim( $join_type ) );
if ( $this->tables['orders'] === $alias ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
}
if ( empty( $on ) ) {
if ( $this->tables['orders'] === $table ) {
$on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
} else {
$on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
}
if ( isset( $this->join[ $alias ] ) ) {
if ( ! $alias_once ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
}
return;
}
if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
$join_type = 'INNER';
}
$sql_join = '';
$sql_join .= "{$join_type} JOIN `{$table}` ";
$sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
$sql_join .= "ON ( {$on} )";
$this->join[ $alias ] = $sql_join;
}
/**
* Generates a properly escaped and sanitized WHERE condition for a given field.
*
* @param string $table The table the field belongs to.
* @param string $field The field or column name.
* @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
* @param mixed $value The value.
* @param string $type The column type as specified in {@see OrdersTableDataStore} column mappings.
* @return string The resulting WHERE condition.
*/
public function where( string $table, string $field, string $operator, $value, string $type ): string {
global $wpdb;
$db_util = wc_get_container()->get( DatabaseUtil::class );
$operator = strtoupper( '' !== $operator ? $operator : '=' );
try {
$format = $db_util->get_wpdb_format_for_type( $type );
} catch ( \Exception $e ) {
$format = '%s';
}
// = and != can be shorthands for IN and NOT in for array values.
if ( is_array( $value ) && '=' === $operator ) {
$operator = 'IN';
} elseif ( is_array( $value ) && '!=' === $operator ) {
$operator = 'NOT IN';
}
if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN' ), true ) ) {
return false;
}
if ( is_array( $value ) ) {
$value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
} else {
$value = $db_util->format_object_value_for_db( $value, $type );
}
if ( is_array( $value ) ) {
$placeholder = array_fill( 0, count( $value ), $format );
$placeholder = '(' . implode( ',', $placeholder ) . ')';
} else {
$placeholder = $format;
}
$sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $sql;
}
/**
* Processes fields related to the orders table.
*
* @return void
*/
private function process_orders_table_query_args(): void {
$this->sanitize_status();
$fields = array_filter(
array(
'id',
'status',
'type',
'currency',
'tax_amount',
'customer_id',
'billing_email',
'total_amount',
'parent_order_id',
'payment_method',
'payment_method_title',
'transaction_id',
'ip_address',
'user_agent',
),
array( $this, 'arg_isset' )
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
}
if ( $this->arg_isset( 'parent_exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
}
if ( $this->arg_isset( 'exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
}
// 'customer' is a very special field.
if ( $this->arg_isset( 'customer' ) ) {
$customer_query = $this->generate_customer_query( $this->args['customer'] );
if ( $customer_query ) {
$this->where[] = $customer_query;
}
}
}
/**
* Generate SQL conditions for the 'customer' query.
*
* @param array $values List of customer ids or emails.
* @param string $relation 'OR' or 'AND' relation used to build the customer query.
* @return string SQL to be used in a WHERE clause.
*/
private function generate_customer_query( $values, string $relation = 'OR' ): string {
$values = is_array( $values ) ? $values : array( $values );
$ids = array();
$emails = array();
foreach ( $values as $value ) {
if ( is_array( $value ) ) {
$sql = $this->generate_customer_query( $value, 'AND' );
$pieces[] = $sql ? '(' . $sql . ')' : '';
} elseif ( is_numeric( $value ) ) {
$ids[] = absint( $value );
} elseif ( is_string( $value ) && is_email( $value ) ) {
$emails[] = sanitize_email( $value );
} else {
// Invalid query.
$pieces[] = '1=0';
}
}
if ( $ids ) {
$pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
}
if ( $emails ) {
$pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
}
return $pieces ? implode( " $relation ", $pieces ) : '';
}
/**
* Processes fields related to the operational data table.
*
* @return void
*/
private function process_operational_data_table_query_args(): void {
$fields = array_filter(
array(
'created_via',
'woocommerce_version',
'prices_include_tax',
'order_key',
'discount_total_amount',
'discount_tax_amount',
'shipping_total_amount',
'shipping_tax_amount',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
return;
}
$this->join(
$this->tables['operational_data'],
'',
'',
'inner',
true
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
}
}
/**
* Processes fields related to the addresses table.
*
* @return void
*/
private function process_addresses_table_query_args(): void {
global $wpdb;
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$fields = array_filter(
array(
$address_type . '_first_name',
$address_type . '_last_name',
$address_type . '_company',
$address_type . '_address_1',
$address_type . '_address_2',
$address_type . '_city',
$address_type . '_state',
$address_type . '_postcode',
$address_type . '_country',
$address_type . '_phone',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
continue;
}
$this->join(
$this->tables['addresses'],
$address_type,
$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'inner',
false
);
foreach ( $fields as $arg_key ) {
$column_name = str_replace( "{$address_type}_", '', $arg_key );
$this->where[] = $this->where(
$address_type,
$column_name,
'=',
$this->args[ $arg_key ],
$this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
);
}
}
}
/**
* Generates the ORDER BY clause.
*
* @return void
*/
private function process_orderby(): void {
// 'order' and 'orderby' vars.
$this->args['order'] = $this->sanitize_order( $this->args['order'] ?? '' );
$this->sanitize_order_orderby();
$orderby = $this->args['orderby'];
if ( 'none' === $orderby ) {
$this->orderby = '';
return;
}
if ( 'include' === $orderby || 'post__in' === $orderby ) {
$ids = $this->args['id'] ?? $this->args['includes'];
if ( empty( $ids ) ) {
return;
}
$ids = array_map( 'absint', $ids );
$this->orderby = array( "FIELD( {$this->tables['orders']}.id, " . implode( ',', $ids ) . ' )' );
return;
}
$meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : array();
$orderby_array = array();
foreach ( $this->args['orderby'] as $_orderby => $order ) {
if ( in_array( $_orderby, $meta_orderby_keys, true ) ) {
$_orderby = $this->meta_query->get_orderby_clause_for_key( $_orderby );
}
$orderby_array[] = "{$_orderby} {$order}";
}
$this->orderby = $orderby_array;
}
/**
* Generates the limits to be used in the LIMIT clause.
*
* @return void
*/
private function process_limit(): void {
$row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
$page = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
$offset = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );
// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
if ( false === $row_count || $row_count < -1 ) {
return;
}
if ( false === $offset && $row_count > -1 ) {
$offset = (int) ( ( $page - 1 ) * $row_count );
}
$this->limits = array( $offset, $row_count );
}
/**
* Checks if a query var is set (i.e. not one of the "skipped values").
*
* @param string $arg_key Query var.
* @return bool TRUE if query var is set.
*/
public function arg_isset( string $arg_key ): bool {
return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
}
/**
* Runs the SQL query.
*
* @return void
*/
private function run_query(): void {
global $wpdb;
// Run query.
$this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Set max_num_pages and found_orders if necessary.
if ( ( $this->arg_isset( 'no_found_rows' ) && ! $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
return;
}
if ( $this->limits ) {
$this->found_orders = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
} else {
$this->found_orders = count( $this->orders );
}
}
/**
* Make some private available for backwards compatibility.
*
* @param string $name Property to get.
* @return mixed
*/
public function __get( string $name ) {
switch ( $name ) {
case 'found_orders':
case 'found_posts':
return $this->found_orders;
case 'max_num_pages':
return $this->max_num_pages;
case 'posts':
case 'orders':
return $this->orders;
case 'request':
return $this->sql;
default:
break;
}
}
/**
* Returns the value of one of the query arguments.
*
* @param string $arg_name Query var.
* @return mixed
*/
public function get( string $arg_name ) {
return $this->args[ $arg_name ] ?? null;
}
/**
* Returns the name of one of the OrdersTableDatastore tables.
*
* @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
* @return string The prefixed table name.
* @throws \Exception When table ID is not found.
*/
public function get_table_name( string $table_id = '' ): string {
if ( ! isset( $this->tables[ $table_id ] ) ) {
// Translators: %s is a table identifier.
throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
}
return $this->tables[ $table_id ];
}
/**
* Finds table and mapping information about a field or column.
*
* @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
* @return false|array {
* @type string $table Full table name where the field is located.
* @type string $mapping_id Unprefixed table or mapping name.
* @type string $field_name Name of the corresponding order field.
* @type string $column Column in $table that corresponds to the field.
* @type string $type Field type.
* }
*/
public function get_field_mapping_info( $field ) {
global $wpdb;
$result = array(
'table' => '',
'mapping_id' => '',
'field_name' => '',
'column' => '',
'column_type' => '',
);
$mappings_to_search = array();
if ( false !== strstr( $field, '.' ) ) {
list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );
$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;
if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
$result['mapping_id'] = $mapping_or_table;
$result['column'] = $field_name_or_col;
} else {
$mappings_to_search = array( $mapping_or_table );
}
}
} else {
$field_name_or_col = $field;
$mappings_to_search = array_keys( $this->mappings );
}
foreach ( $mappings_to_search as $mapping_id ) {
foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
$result['mapping_id'] = $mapping_id;
$result['column'] = $column_name;
break 2;
}
}
}
if ( ! $result['mapping_id'] || ! $result['column'] ) {
return false;
}
$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];
$result['field_name'] = $field_info['name'];
$result['column_type'] = $field_info['type'];
$result['table'] = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
? $this->tables['addresses']
: $this->tables[ $result['mapping_id'] ];
return $result;
}
}
Orders/OrdersTableRefundDataStore.php 0000644 00000013767 15154263117 0013717 0 ustar 00 <?php
/**
* Order refund data store. Refunds are based on orders (essentially negative orders) but there is slight difference in how we save them.
* For example, order save hooks etc can't be fired when saving refund, so we need to do it a separate datastore.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use \WC_Cache_Helper;
use \WC_Meta_Data;
/**
* Class OrdersTableRefundDataStore.
*/
class OrdersTableRefundDataStore extends OrdersTableDataStore {
/**
* Data stored in meta keys, but not considered "meta" for refund.
*
* @var string[]
*/
protected $internal_meta_keys = array(
'_refund_amount',
'_refund_reason',
'_refunded_by',
'_refunded_payment',
);
/**
* We do not have and use all the getters and setters from OrderTableDataStore, so we only select the props we actually need.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
);
/**
* Delete a refund order from database.
*
* @param \WC_Order $refund Refund object to delete.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$refund, $args = array() ) {
$refund_id = $refund->get_id();
if ( ! $refund_id ) {
return;
}
$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $refund->get_parent_id();
wp_cache_delete( $refund_cache_key, 'orders' );
$this->delete_order_data_from_custom_order_tables( $refund_id );
$refund->set_id( 0 );
$orders_table_is_authoritative = $refund->get_data_store()->get_current_class_name() === self::class;
if ( $orders_table_is_authoritative ) {
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $refund_id );
} else {
$this->handle_order_deletion_with_sync_disabled( $refund_id );
}
}
}
/**
* Helper method to set refund props.
*
* @param \WC_Order_Refund $refund Refund object.
* @param object $data DB data object.
*
* @since 8.0.0
*/
protected function set_order_props_from_data( &$refund, $data ) {
parent::set_order_props_from_data( $refund, $data );
foreach ( $data->meta_data as $meta ) {
switch ( $meta->meta_key ) {
case '_refund_amount':
$refund->set_amount( $meta->meta_value );
break;
case '_refunded_by':
$refund->set_refunded_by( $meta->meta_value );
break;
case '_refunded_payment':
$refund->set_refunded_payment( wc_string_to_bool( $meta->meta_value ) );
break;
case '_refund_reason':
$refund->set_reason( $meta->meta_value );
break;
}
}
}
/**
* Method to create a refund in the database.
*
* @param \WC_Abstract_Order $refund Refund object.
*/
public function create( &$refund ) {
$refund->set_status( 'completed' ); // Refund are always marked completed.
$this->persist_save( $refund );
}
/**
* Update refund in database.
*
* @param \WC_Order $refund Refund object.
*/
public function update( &$refund ) {
$this->persist_updates( $refund );
}
/**
* Helper method that updates post meta based on an refund object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $refund Refund object.
*/
public function update_order_meta( &$refund ) {
parent::update_order_meta( $refund );
// Update additional props.
$updated_props = array();
$meta_key_to_props = array(
'_refund_amount' => 'amount',
'_refunded_by' => 'refunded_by',
'_refunded_payment' => 'refunded_payment',
'_refund_reason' => 'reason',
);
$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
foreach ( $props_to_update as $meta_key => $prop ) {
$meta_object = new WC_Meta_Data();
$meta_object->key = $meta_key;
$meta_object->value = $refund->{"get_$prop"}( 'edit' );
$existing_meta = $this->data_store_meta->get_metadata_by_key( $refund, $meta_key );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta_object->id = $existing_meta->id;
$this->update_meta( $refund, $meta_object );
} else {
$this->add_meta( $refund, $meta_object );
}
$updated_props[] = $prop;
}
/**
* Fires after updating meta for a order refund.
*
* @since 2.7.0
*/
do_action( 'woocommerce_order_refund_object_updated_props', $refund, $updated_props );
}
/**
* Get a title for the new post type.
*
* @return string
*/
protected function get_post_title() {
return sprintf(
/* translators: %s: Order date */
__( 'Refund – %s', 'woocommerce' ),
( new \DateTime( 'now' ) )->format( _x( 'M d, Y @ h:i A', 'Order date parsed by DateTime::format', 'woocommerce' ) ) // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText
);
}
/**
* Returns data store object to use backfilling.
*
* @return \WC_Order_Refund_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Refund_Data_Store_CPT();
}
}
Orders/OrdersTableSearchQuery.php 0000644 00000011022 15154263117 0013076 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Exception;
/**
* Creates the join and where clauses needed to perform an order search using Custom Order Tables.
*
* @internal
*/
class OrdersTableSearchQuery {
/**
* Holds the Orders Table Query object.
*
* @var OrdersTableQuery
*/
private $query;
/**
* Holds the search term to be used in the WHERE clauses.
*
* @var string
*/
private $search_term;
/**
* Creates the JOIN and WHERE clauses needed to execute a search of orders.
*
* @internal
*
* @param OrdersTableQuery $query The order query object.
*/
public function __construct( OrdersTableQuery $query ) {
global $wpdb;
$this->query = $query;
$this->search_term = esc_sql( '%' . $wpdb->esc_like( urldecode( $query->get( 's' ) ) ) . '%' );
}
/**
* Supplies an array of clauses to be used in an order query.
*
* @internal
* @throws Exception If unable to generate either the JOIN or WHERE SQL fragments.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => array( $this->generate_join() ),
'where' => array( $this->generate_where() ),
);
}
/**
* Generates the necessary JOIN clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_join(): string {
$orders_table = $this->query->get_table_name( 'orders' );
$items_table = $this->query->get_table_name( 'items' );
return "
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
}
/**
* Generates the necessary WHERE clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_where(): string {
global $wpdb;
$where = '';
$possible_order_id = (string) absint( $this->query->get( 's' ) );
$order_table = $this->query->get_table_name( 'orders' );
// Support the passing of an order ID as the search term.
if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
$where = "`$order_table`.id = $possible_order_id OR ";
}
$meta_sub_query = $this->generate_where_for_meta_table();
$where .= $wpdb->prepare(
"
search_query_items.order_item_name LIKE %s
OR `$order_table`.id IN ( $meta_sub_query )
",
$this->search_term
);
return " ( $where ) ";
}
/**
* Generates where clause for meta table.
*
* Note we generate the where clause as a subquery to be used by calling function inside the IN clause. This is against the general wisdom for performance, but in this particular case, a subquery is able to use the order_id-meta_key-meta_value index, which is not possible with a join.
*
* Since it can use the index, which otherwise would not be possible, it is much faster than both LEFT JOIN or SQL_CALC approach that could have been used.
*
* @return string The where clause for meta table.
*/
private function generate_where_for_meta_table(): string {
global $wpdb;
$meta_table = $this->query->get_table_name( 'meta' );
$meta_fields = $this->get_meta_fields_to_be_searched();
return $wpdb->prepare(
"
SELECT search_query_meta.order_id
FROM $meta_table as search_query_meta
WHERE search_query_meta.meta_key IN ( $meta_fields )
AND search_query_meta.meta_value LIKE %s
GROUP BY search_query_meta.order_id
",
$this->search_term
);
}
/**
* Returns the order meta field keys to be searched.
*
* These will be returned as a single string, where the meta keys have been escaped, quoted and are
* comma-separated (ie, "'abc', 'foo'" - ready for inclusion in a SQL IN() clause).
*
* @return string
*/
private function get_meta_fields_to_be_searched(): string {
/**
* Controls the order meta keys to be included in search queries.
*
* This hook is used when Custom Order Tables are in use: the corresponding hook when CPT-orders are in use
* is 'woocommerce_shop_order_search_fields'.
*
* @since 7.0.0
*
* @param array
*/
$meta_keys = apply_filters(
'woocommerce_order_table_search_query_meta_keys',
array(
'_billing_address_index',
'_shipping_address_index',
)
);
$meta_keys = (array) array_map(
function ( string $meta_key ): string {
return "'" . esc_sql( wc_clean( $meta_key ) ) . "'";
},
$meta_keys
);
return implode( ',', $meta_keys );
}
}