8889841cPK;[/<~E~EOrders/DataSynchronizer.phpnu[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' ); } /** * 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() ); return count( $missing_tables ) === 0; } /** * Create the custom orders database tables. */ public function create_database_tables() { $this->database_util->dbdelta( $this->data_store->get_database_schema() ); } /** * 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 ); } } /** * 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; } } $orders_table = $this->data_store::get_orders_table_name(); $order_post_types = wc_get_order_types( 'cot-migration' ); 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; } $order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) ); if ( $this->custom_orders_table_is_authoritative() ) { $missing_orders_count_sql = " 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' ) "; $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 ); wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count ); return $pending_count; } /** * 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. * * @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_post_types ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare break; case self::ID_TYPE_MISSING_IN_POSTS_TABLE: $sql = " 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' ) "; 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_post_types ); // phpcs:enable break; 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" ) ); } /** * 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 ( $this->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(); } } /** * 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 { if ( $this->custom_orders_table_is_authoritative() ) { $order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_MISSING_IN_POSTS_TABLE, $size ); } else { $order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_MISSING_IN_ORDERS_TABLE, $size ); } if ( count( $order_ids ) >= $size ) { return $order_ids; } $order_ids = $order_ids + $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) ); return $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 { if ( 'shop_order' === $post->post_type && $this->data_sync_is_enabled() ) { $this->data_store->delete_order_data_from_custom_order_tables( $postid ); } } /** * 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 ) ); } } /** * 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( __( "Manage orders synchronization", 'woocommerce' ), $cot_settings_url ); return $desc_tip ? "{$desc_tip}
{$extra_tip} {$manage_cot_settings_link}" : "{$extra_tip} {$manage_cot_settings_link}"; } } PK;[ ]#Orders/OrdersTableDataStoreMeta.phpnu[ 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; } $this->delete_order_data_from_custom_order_tables( $refund_id ); $refund->set_id( 0 ); // If this datastore method is called while the posts table is authoritative, refrain from deleting post data. if ( ! is_a( $refund->get_data_store(), self::class ) ) { return; } // 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 ); } /** * Read a refund object from custom tables. * * @param \WC_Abstract_Order $refund Refund object. * * @return void */ public function read( &$refund ) { parent::read( $refund ); $this->set_refund_props( $refund ); } /** * Read multiple refund objects from custom tables. * * @param \WC_Order $refunds Refund objects. */ public function read_multiple( &$refunds ) { parent::read_multiple( $refunds ); foreach ( $refunds as $refund ) { $this->set_refund_props( $refund ); } } /** * Helper method to set refund props. * * @param \WC_Order $refund Refund object. */ private function set_refund_props( $refund ) { $refund->set_props( array( 'amount' => $refund->get_meta( '_refund_amount', true ), 'refunded_by' => $refund->get_meta( '_refunded_by', true ), 'refunded_payment' => wc_string_to_bool( $refund->get_meta( '_refunded_payment', true ) ), 'reason' => $refund->get_meta( '_refund_reason', true ), ) ); } /** * 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 ) { $value = $refund->{"get_$prop"}( 'edit' ); $refund->update_meta_data( $meta_key, $value ); $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(); } } PK;[s<(<(Orders/OrdersTableDataStore.phpnu[data_store_meta = $data_store_meta; $this->database_util = $database_util; $this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' ); $this->internal_meta_keys = $this->get_internal_meta_keys(); } /** * 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. * * @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(), ); } /** * 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 op table, this is used in select query. * * @return string Alias. */ private function get_op_table_alias() : string { return 'order_operational_data'; } /** * Helper function to get alias for address table, this is used in select query. * * @param string $type Address type. * * @return string Alias. */ private function get_address_table_alias( string $type ) : string { return "address_$type"; } /** * 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; } $cpt_data_store->update_order_from_object( $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 ), ) ); } } } /** * 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 = is_int( $order ) ? wc_get_order( $order ) : $order; 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 = is_int( $order ) ? wc_get_order( $order ) : $order; 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 = is_int( $order ) ? wc_get_order( $order ) : $order; 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 = is_int( $order ) ? wc_get_order( $order ) : $order; 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 ) { $order = is_int( $order ) ? wc_get_order( $order ) : $order; return $order->get_new_order_email_sent(); } /** * 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 = is_int( $order ) ? wc_get_order( $order ) : $order; 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 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", $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 ] ?? ''; } /** * 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() && 0 === $data_synchronizer->get_current_orders_pending_sync_count_cached(); $load_posts_for = array_diff( $order_ids, self::$reading_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 { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- This is a log function. $this->error_logger->notice( 'Diff found: ' . print_r( $diff, true ) ); } /** * 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. */ private 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; } if ( 'date' === $prop_details['type'] ) { $prop_value = $this->string_to_timestamp( $prop_value ); } $this->set_order_prop( $order, $prop_details['name'], $prop_value ); } } } /** * 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 ) { if ( ! $ids ) { return array(); } global $wpdb; if ( empty( $ids ) ) { return array(); } $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 wc_order.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 ) { $order_data[ $table_datum->id ] = $table_datum; $order_data[ $table_datum->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 = 'wc_order'; $select_clause = $this->generate_select_clause_for_props( $order_table_alias, $this->order_column_mapping ); $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 ); return " SELECT $select_clause, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, {$operational_data_clauses['select']} 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. Typically will be `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', ) ); 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 ); } /** * 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() ) ), 'format' => array_merge( $row['format'], array( 'id' => '%d' ) ), ); } // 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() ); $changes['type'] = $order->get_type(); // Make sure 'status' is correct. if ( array_key_exists( 'status', $column_mapping ) ) { $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; } if ( ! empty( $args['force_delete'] ) ) { /** * 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_child_orders( $order ); $this->delete_order_data_from_custom_order_tables( $order_id ); $order->set_id( 0 ); // If this datastore method is called while the posts table is authoritative, refrain from deleting post data. if ( $order->get_data_store()->get_current_class_name() !== self::class ) { return; } // 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 ); do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } else { /** * Fires immediately before an order is trashed. * * @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_trash_order', $order_id, $order ); $this->trash_order( $order ); do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } } /** * Helper method to set child orders to the parent order's parent. * * @param \WC_Abstract_Order $order Order object. * * @return void */ private function upshift_child_orders( $order ) { global $wpdb; $order_table = self::get_orders_table_name(); $order_parent = $order->get_parent_id(); $wpdb->update( $order_table, array( 'parent_order_id' => $order_parent ), array( 'parent_order_id' => $order->get_id() ), array( '%d' ), array( '%d' ) ); } /** * 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(), ); foreach ( $trash_metadata as $meta_key => $meta_value ) { $this->add_meta( $order, (object) array( 'key' => $meta_key, 'value' => $meta_value, ) ); } $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' ); $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(); $data_synchronizer = wc_get_container()->get( DataSynchronizer::class ); if ( $data_synchronizer->data_sync_is_enabled() ) { // The previous $order->save() will have forced a sync to the posts table, // this implies that the post status is not "trash" anymore, and thus // wp_untrash_post would do nothing. wp_update_post( array( 'ID' => $id, 'post_status' => 'trash', ) ); wp_untrash_post( $id ); } 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; // 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' ) ); } } /** * 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 ); /** * 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() ); } $this->update_order_meta( $order ); $this->persist_order_to_db( $order, $force_all_fields ); $order->save_meta_data(); $order->apply_changes(); if ( $backfill ) { $this->maybe_backfill_post_record( $order ); } $this->clear_caches( $order ); } /** * Method to update an order in the database. * * @param \WC_Order $order Order object. */ public function update( &$order ) { // 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 ); 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( time() ); } if ( $backfill ) { $this->maybe_backfill_post_record( $order ); } $this->persist_order_to_db( $order ); $order->save_meta_data(); return $changes; } /** * 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 ) { $data_sync = wc_get_container()->get( DataSynchronizer::class ); if ( $data_sync->data_sync_is_enabled() ) { $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(); $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), KEY billing_email (billing_email), KEY type_status (type, status), 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), 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), UNIQUE 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, meta_value(100)), KEY order_id_meta_key_meta_value (order_id, meta_key, meta_value(100)) ) $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). */ public function delete_meta( &$object, $meta ) { return $this->data_store_meta->delete_meta( $object, $meta ); } /** * Add new piece of meta. * * @param WC_Data $object WC_Data object. * @param stdClass $meta (containing ->key and ->value). * @return int meta ID */ public function add_meta( &$object, $meta ) { return $this->data_store_meta->add_meta( $object, $meta ); } /** * Update meta. * * @param WC_Data $object WC_Data object. * @param stdClass $meta (containing ->id, ->key and ->value). */ public function update_meta( &$object, $meta ) { return $this->data_store_meta->update_meta( $object, $meta ); } } PK;[b=!Orders/OrdersTableSearchQuery.phpnu[query = $query; $this->search_term = "'" . esc_sql( '%' . 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' ); $meta_table = $this->query->get_table_name( 'meta' ); $items_table = $this->query->get_table_name( 'items' ); return " LEFT JOIN $meta_table AS search_query_meta ON search_query_meta.order_id = $orders_table.id 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 { $where = ''; $meta_fields = $this->get_meta_fields_to_be_searched(); $possible_order_id = (string) absint( $this->query->get( 's' ) ); // Support the passing of an order ID as the search term. if ( (string) $this->query->get( 's' ) === $possible_order_id ) { $where = $this->query->get_table_name( 'orders' ) . '.id = ' . $possible_order_id . ' OR '; } $where .= " ( search_query_meta.meta_key IN ( $meta_fields ) AND search_query_meta.meta_value LIKE $this->search_term ) OR search_query_items.order_item_name LIKE $this->search_term "; return " ( $where ) "; } /** * 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 ); } } PK;[el!! Orders/OrdersTableFieldQuery.phpnu[', '>=', '<', '<=', '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[] = $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(), ); } } PK;[buJJOrders/OrdersTableMetaQuery.phpnu[', '>=', '<', '<=', '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}"; } } } } PK;[m ԋԋOrders/OrdersTableQuery.phpnu[]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/'; /** * 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 $results = 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; /** * Sets up and runs the query after processing arguments. * * @param array $args Array of query vars. */ public function __construct( $args = array() ) { global $wpdb; $datastore = wc_get_container()->get( OrdersTableDataStore::class ); // TODO: maybe OrdersTableDataStore::get_all_table_names() could return these keys/indices instead. $this->tables = array( 'orders' => $datastore::get_orders_table_name(), 'addresses' => $datastore::get_addresses_table_name(), 'operational_data' => $datastore::get_operational_data_table_name(), 'meta' => $datastore::get_meta_table_name(), 'items' => $wpdb->prefix . 'woocommerce_order_items', ); $this->mappings = $datastore->get_all_order_column_mappings(); $this->args = $args; // TODO: args to be implemented. unset( $this->args['customer_note'], $this->args['name'] ); $this->build_query(); $this->run_query(); } /** * 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_gmt', 'post_date_gmt' => 'date_created_gmt', 'post_modified' => 'date_modified_gmt', 'post_modified_gmt' => 'date_updated_gmt', 'post_status' => 'status', '_date_completed' => 'date_completed_gmt', '_date_paid' => 'date_paid_gmt', '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_created' => 'date_created_gmt', 'date_modified' => 'date_updated_gmt', 'date_modified_gmt' => 'date_updated_gmt', 'date_completed' => 'date_completed_gmt', 'date_completed_gmt' => 'date_completed_gmt', 'date_paid' => 'date_paid_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' => '', ); $precision = 'second'; if ( is_numeric( $date ) ) { $date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) ); } elseif ( ! is_a( $date, 'WC_DateTime' ) ) { // YYYY-MM-DD queries have 'day' precision for backwards compat. $date = wc_string_to_datetime( $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; } /** * 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 { $valid_operators = array( '>', '>=', '=', '<=', '<', '...' ); $date_queries = array(); $gmt_date_keys = array( 'date_created_gmt', 'date_updated_gmt', 'date_paid_gmt', 'date_completed_gmt', ); foreach ( array_filter( $gmt_date_keys, array( $this, 'arg_isset' ) ) as $date_key ) { $date_value = $this->args[ $date_key ]; $operator = '='; $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[] = $this->date_to_date_query_arg( $matches[1] ); } $dates[] = $this->date_to_date_query_arg( $matches[3] ); } else { $dates[] = $this->date_to_date_query_arg( $date_value ); } if ( empty( $dates ) || ! $operator || ( '...' === $operator && count( $dates ) < 2 ) ) { throw new \Exception( 'Invalid date_query' ); } $operator_to_keys = array(); if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) { $operator_to_keys[] = 'after'; } if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) { $operator_to_keys[] = 'before'; } $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(); } /** * 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 ? ( 'ORDER BY ' . 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 ? 'GROUP BY ' . implode( ', ', (array) $this->groupby ) : ''; $this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits"; $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 ); } } 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->results; 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 `.` format or just ``. * @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; } } PK;[pƳUU&Orders/CustomOrdersTableController.phpnu[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( 'woocommerce_get_sections_advanced', array( $this, 'get_settings_sections' ), 999, 1 ); self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'get_settings' ), 999, 2 ); 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_filter( DataSynchronizer::PENDING_SYNCHRONIZATION_FINISHED_ACTION, array( $this, 'process_sync_finished' ), 10, 0 ); self::add_action( 'woocommerce_update_options_advanced_custom_data_stores', array( $this, 'process_options_updated' ), 10, 0 ); 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 ); } /** * 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. */ final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer, OrdersTableRefundDataStore $refund_data_store, BatchProcessingController $batch_processing_controller, FeaturesController $features_controller ) { $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; } /** * Checks if the feature is visible (so that dedicated entries will be added to the debug tools page). * * @return bool True if the feature is visible. */ public function is_feature_visible(): bool { return $this->features_controller->feature_is_enabled( 'custom_order_tables' ); } /** * Makes the feature visible, so that dedicated entries will be added to the debug tools page. * * This method shouldn't be used anymore, see the FeaturesController class. */ public function show_feature() { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, sprintf( // translators: %1$s the name of the class and method used. __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), $class_and_method ), '7.0' ); } /** * Hides the feature, so that no entries will be added to the debug tools page. * * This method shouldn't be used anymore, see the FeaturesController class. */ public function hide_feature() { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, sprintf( // translators: %1$s the name of the class and method used. __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), $class_and_method ), '7.0' ); } /** * 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 $this->is_feature_visible() && 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->is_feature_visible() ) { $disabled = true; $message = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" feature 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( '%1$s %2$s', __( 'Note:', 'woocommerce' ), $message ), 'requires_refresh' => true, 'callback' => function () { $this->features_controller->change_feature_enable( 'custom_order_tables', false ); $this->delete_custom_orders_tables(); return __( 'Custom orders tables have been deleted.', 'woocommerce' ); }, 'button' => __( 'Delete', 'woocommerce' ), 'disabled' => $disabled, ); return $tools_array; } /** * Create the custom orders tables in response to the user pressing the tool button. * * @param bool $verify_nonce True to perform the nonce verification, false to skip it. * * @throws \Exception Can't create the tables. */ private function create_custom_orders_tables( bool $verify_nonce = true ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput if ( $verify_nonce && ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) ) { throw new \Exception( 'Invalid nonce' ); } $this->data_synchronizer->create_database_tables(); update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' ); } /** * 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 > Custom data stores)." ); } delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ); $this->data_synchronizer->delete_database_tables(); } /** * Get the settings sections for the "Advanced" tab, with a "Custom data stores" section added if appropriate. * * @param array $sections The original settings sections array. * @return array The updated settings sections array. */ private function get_settings_sections( array $sections ): array { if ( ! $this->is_feature_visible() ) { return $sections; } $sections['custom_data_stores'] = __( 'Custom data stores', 'woocommerce' ); return $sections; } /** * Get the settings for the "Custom data stores" section in the "Advanced" tab, * with entries for managing the custom orders tables if appropriate. * * @param array $settings The original settings array. * @param string $section_id The settings section to get the settings for. * @return array The updated settings array. */ private function get_settings( array $settings, string $section_id ): array { if ( ! $this->is_feature_visible() || 'custom_data_stores' !== $section_id ) { return $settings; } $settings[] = array( 'title' => __( 'Custom orders tables', 'woocommerce' ), 'type' => 'title', 'id' => 'cot-title', 'desc' => sprintf( /* translators: %1$s = tag, %2$s = tag. */ __( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ), '', '' ), ); $sync_status = $this->data_synchronizer->get_sync_status(); $sync_is_pending = 0 !== $sync_status['current_pending_count']; $settings[] = array( 'title' => __( 'Data store for orders', 'woocommerce' ), 'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'default' => 'no', 'type' => 'radio', 'options' => array( 'yes' => __( 'Use the WooCommerce orders tables', 'woocommerce' ), 'no' => __( 'Use the WordPress posts table', 'woocommerce' ), ), 'checkboxgroup' => 'start', 'disabled' => $sync_is_pending ? array( 'yes', 'no' ) : array(), ); if ( $sync_is_pending ) { $initial_pending_count = $sync_status['initial_pending_count']; $current_pending_count = $sync_status['current_pending_count']; if ( $initial_pending_count ) { $text = sprintf( /* translators: %1$s=current number of orders pending sync, %2$s=initial number of orders pending sync */ _n( 'There\'s %1$s order (out of a total of %2$s) pending sync!', 'There are %1$s orders (out of a total of %2$s) pending sync!', $current_pending_count, 'woocommerce' ), $current_pending_count, $initial_pending_count ); } else { $text = /* translators: %s=initial number of orders pending sync */ sprintf( _n( 'There\'s %s order pending sync!', 'There are %s orders pending sync!', $current_pending_count, 'woocommerce' ), $current_pending_count, 'woocommerce' ); } if ( $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) ) ) { $text .= __( "
Synchronization for these orders is currently in progress.
The authoritative table can't be changed until sync completes.", 'woocommerce' ); } else { $text .= __( "
The authoritative table can't be changed until these orders are synchronized.", 'woocommerce' ); } $settings[] = array( 'type' => 'info', 'id' => 'cot-out-of-sync-warning', 'css' => 'color: #C00000', 'text' => $text, ); } $settings[] = array( 'desc' => __( 'Keep the posts table and the orders tables synchronized', 'woocommerce' ), 'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'type' => 'checkbox', ); if ( $sync_is_pending ) { if ( $this->data_synchronizer->data_sync_is_enabled() ) { $message = $this->custom_orders_table_usage_is_enabled() ? __( 'Switch to using the posts table as the authoritative data store for orders when sync finishes', 'woocommerce' ) : __( 'Switch to using the orders table as the authoritative data store for orders when sync finishes', 'woocommerce' ); $settings[] = array( 'desc' => $message, 'id' => self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'type' => 'checkbox', ); } } $settings[] = array( 'desc' => __( 'Use database transactions for the orders data synchronization', 'woocommerce' ), 'id' => self::USE_DB_TRANSACTIONS_OPTION, 'type' => 'checkbox', ); $isolation_level_names = self::get_valid_transaction_isolation_levels(); $settings[] = array( 'desc' => __( 'Database transaction isolation level to use', 'woocommerce' ), 'id' => self::DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION, 'type' => 'select', 'options' => array_combine( $isolation_level_names, $isolation_level_names ), 'default' => self::DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL, ); $settings[] = array( 'type' => 'sectionend' ); return $settings; } /** * Get the valid database transaction isolation level names. * * @return string[] */ public static function get_valid_transaction_isolation_levels() { return array( 'REPEATABLE READ', 'READ COMMITTED', 'READ UNCOMMITTED', 'SERIALIZABLE', ); } /** * 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 ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) { return $value; } /** * Commenting out for better testability. $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 synchronization finished hook. * Here we switch the authoritative table if needed. */ private function process_sync_finished() { if ( ! $this->auto_flip_authoritative_table_enabled() ) { return; } update_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' ); if ( $this->custom_orders_table_usage_is_enabled() ) { update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' ); } else { update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' ); } } /** * Is the automatic authoritative table switch setting set? * * @return bool */ private function auto_flip_authoritative_table_enabled(): bool { return get_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ) === 'yes'; } /** * Handler for the all settings updated hook. */ private function process_options_updated() { $data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled(); // Disabling the sync implies disabling the automatic authoritative table switch too. if ( ! $data_sync_is_enabled && $this->auto_flip_authoritative_table_enabled() ) { update_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' ); } // 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 ( 'custom_order_tables' !== $feature_id || ! $is_enabled ) { return; } if ( ! $this->data_synchronizer->check_orders_table_exists() ) { update_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' ); $this->create_custom_orders_tables( false ); } } /** * 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, ) ); } } PK;[)CustomMetaDataStore.phpnu[ $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). */ public function delete_meta( &$object, $meta ) { 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 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). */ public function update_meta( &$object, $meta ) { 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; } } PK;[/<~E~EOrders/DataSynchronizer.phpnu[PK;[ ]#EOrders/OrdersTableDataStoreMeta.phpnu[PK;[ί%%%HOrders/OrdersTableRefundDataStore.phpnu[PK;[s<(<(t^Orders/OrdersTableDataStore.phpnu[PK;[b=!Orders/OrdersTableSearchQuery.phpnu[PK;[el!! Orders/OrdersTableFieldQuery.phpnu[PK;[buJJOrders/OrdersTableMetaQuery.phpnu[PK;[m ԋԋOrders/OrdersTableQuery.phpnu[PK;[pƳUU&Orders/CustomOrdersTableController.phpnu[PK;[)CustomMetaDataStore.phpnu[PK