PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.4
WooCommerce v10.9.4
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / DataStores / Orders / LegacyDataHandler.php
woocommerce / src / Internal / DataStores / Orders Last commit date
CustomOrdersTableController.php 3 months ago DataSynchronizer.php 7 months ago LegacyDataCleanup.php 1 year ago LegacyDataHandler.php 5 months ago OrdersTableDataStore.php 1 month ago OrdersTableDataStoreMeta.php 1 year ago OrdersTableFieldQuery.php 2 years ago OrdersTableMetaQuery.php 1 year ago OrdersTableQuery.php 1 month ago OrdersTableRefundDataStore.php 1 month ago OrdersTableSearchQuery.php 11 months ago
LegacyDataHandler.php
577 lines
1 <?php
2 /**
3 * LegacyDataHandler class file.
4 */
5
6 namespace Automattic\WooCommerce\Internal\DataStores\Orders;
7
8 use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
9 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
10 use Automattic\WooCommerce\Utilities\ArrayUtil;
11 use WC_Abstract_Order;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * This class provides functionality to clean up post data from the posts table when HPOS is authoritative.
17 */
18 class LegacyDataHandler {
19
20 /**
21 * Instance of the HPOS datastore.
22 *
23 * @var OrdersTableDataStore
24 */
25 private OrdersTableDataStore $data_store;
26
27 /**
28 * Instance of the DataSynchronizer class.
29 *
30 * @var DataSynchronizer
31 */
32 private DataSynchronizer $data_synchronizer;
33
34 /**
35 * Instance of the PostsToOrdersMigrationController.
36 *
37 * @var PostsToOrdersMigrationController
38 */
39 private PostsToOrdersMigrationController $posts_to_cot_migrator;
40
41 /**
42 * Class initialization, invoked by the DI container.
43 *
44 * @param OrdersTableDataStore $data_store HPOS datastore instance to use.
45 * @param DataSynchronizer $data_synchronizer DataSynchronizer instance to use.
46 * @param PostsToOrdersMigrationController $posts_to_cot_migrator Posts to HPOS migration controller instance to use.
47 *
48 * @internal
49 */
50 final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer, PostsToOrdersMigrationController $posts_to_cot_migrator ) {
51 $this->data_store = $data_store;
52 $this->data_synchronizer = $data_synchronizer;
53 $this->posts_to_cot_migrator = $posts_to_cot_migrator;
54 }
55
56 /**
57 * Returns the total number of orders for which legacy post data can be removed.
58 *
59 * @param array $order_ids If provided, total is computed only among IDs in this array, which can be either individual IDs or ranges like "100-200".
60 * @return int Number of orders.
61 */
62 public function count_orders_for_cleanup( $order_ids = array() ): int {
63 global $wpdb;
64 return (int) $wpdb->get_var( $this->build_sql_query_for_cleanup( $order_ids, 'count' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
65 }
66
67 /**
68 * Returns a set of orders for which legacy post data can be removed.
69 *
70 * @param array $order_ids If provided, result is a subset of the order IDs in this array, which can contain either individual order IDs or ranges like "100-200".
71 * @param int $limit Limit the number of results.
72 * @return array[int] Order IDs.
73 */
74 public function get_orders_for_cleanup( $order_ids = array(), int $limit = 0 ): array {
75 global $wpdb;
76
77 return array_map(
78 'absint',
79 $wpdb->get_col( $this->build_sql_query_for_cleanup( $order_ids, 'ids', $limit ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
80 );
81 }
82
83 /**
84 * Builds a SQL statement to either count or obtain IDs for orders in need of cleanup.
85 *
86 * @param array $order_ids If provided, the query will only include orders in this set of order IDs or ID ranges (like "10-100").
87 * @param string $result Use 'count' to build a query that returns a count. Otherwise, the query will return order IDs.
88 * @param integer $limit If provided, the query will be limited to this number of results. Does not apply when $result is 'count'.
89 * @return string SQL query.
90 */
91 private function build_sql_query_for_cleanup( array $order_ids = array(), string $result = 'ids', int $limit = 0 ): string {
92 global $wpdb;
93
94 $hpos_orders_table = $this->data_store->get_orders_table_name();
95
96 $sql_where = '';
97
98 if ( $order_ids ) {
99 // Expand ranges in $order_ids as needed to build the WHERE clause.
100 $where_ids = array();
101 $where_ranges = array();
102
103 foreach ( $order_ids as &$arg ) {
104 if ( is_numeric( $arg ) ) {
105 $where_ids[] = absint( $arg );
106 } elseif ( preg_match( '/^(\d+)-(\d+)$/', $arg, $matches ) ) {
107 $where_ranges[] = $wpdb->prepare( "({$wpdb->posts}.ID >= %d AND {$wpdb->posts}.ID <= %d)", absint( $matches[1] ), absint( $matches[2] ) );
108 }
109 }
110
111 if ( $where_ids ) {
112 $where_ranges[] = "{$wpdb->posts}.ID IN (" . implode( ',', $where_ids ) . ')';
113 }
114
115 if ( ! $where_ranges ) {
116 $sql_where .= '1=0';
117 } else {
118 $sql_where .= '(' . implode( ' OR ', $where_ranges ) . ')';
119 }
120 }
121
122 $sql_where .= $sql_where ? ' AND ' : '';
123
124 // Post type handling.
125 $sql_where .= '(';
126 $sql_where .= "{$wpdb->posts}.post_type IN ('" . implode( "', '", esc_sql( wc_get_order_types( 'cot-migration' ) ) ) . "')";
127 $sql_where .= $wpdb->prepare(
128 " OR (post_type = %s AND ( {$hpos_orders_table}.id IS NULL OR EXISTS(SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = {$wpdb->posts}.ID)) )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
129 $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE
130 );
131 $sql_where .= ')';
132
133 // Exclude 'auto-draft' since those go away on their own.
134 $sql_where .= $wpdb->prepare( " AND {$wpdb->posts}.post_status != %s", 'auto-draft' );
135
136 if ( 'count' === $result ) {
137 $sql_fields = 'COUNT(*)';
138 $sql_limit = '';
139 } else {
140 $sql_fields = "{$wpdb->posts}.ID";
141 $sql_limit = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
142 }
143
144 $sql = "SELECT {$sql_fields} FROM {$wpdb->posts} LEFT JOIN {$hpos_orders_table} ON {$wpdb->posts}.ID = {$hpos_orders_table}.id WHERE {$sql_where} {$sql_limit}";
145 return $sql;
146 }
147
148 /**
149 * Performs a cleanup of post data for a given order and also converts the post to the placeholder type in the backup table.
150 *
151 * @param int $order_id Order ID.
152 * @param bool $skip_checks Whether to skip the checks that happen before the order is cleaned up.
153 * @return void
154 * @throws \Exception When an error occurs.
155 */
156 public function cleanup_post_data( int $order_id, bool $skip_checks = false ): void {
157 global $wpdb;
158
159 $post_type = get_post_type( $order_id );
160 if ( ! in_array( $post_type, array_merge( wc_get_order_types( 'cot-migration' ), array( $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE ) ), true ) ) {
161 // translators: %d is an order ID.
162 throw new \Exception( esc_html( sprintf( __( '%d is not of a valid order type.', 'woocommerce' ), $order_id ) ) );
163 }
164
165 $order_exists = $this->data_store->order_exists( $order_id );
166 if ( $order_exists ) {
167 $order = wc_get_order( $order_id );
168 if ( ! $order ) {
169 // translators: %d is an order ID.
170 throw new \Exception( esc_html( sprintf( __( '%d is not a valid order ID.', 'woocommerce' ), $order_id ) ) );
171 }
172
173 if ( ! $skip_checks && ! $this->is_order_newer_than_post( $order ) ) {
174 // translators: %1 is an order ID.
175 throw new \Exception( esc_html( sprintf( __( 'Data in posts table appears to be more recent than in HPOS tables. Compare order data with `wp wc hpos diff %1$d` and use `wp wc hpos backfill %1$d --from=posts --to=hpos` to fix.', 'woocommerce' ), $order_id ) ) );
176 }
177 }
178
179 $wpdb->delete( $wpdb->postmeta, array( 'post_id' => $order_id ), array( '%d' ) ); // Delete all metadata.
180
181 if ( $order_exists ) {
182 // wp_update_post() changes the post modified date, so we do this manually.
183 // Also, we suspect using wp_update_post() could lead to integrations mistakenly updating the entity.
184 $wpdb->update(
185 $wpdb->posts,
186 array(
187 'post_type' => $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE,
188 'post_status' => 'draft',
189 ),
190 array( 'ID' => $order_id ),
191 array( '%s', '%s' ),
192 array( '%d' )
193 );
194 } else {
195 $wpdb->delete( $wpdb->posts, array( 'ID' => $order_id ), array( '%d' ) );
196 }
197
198 clean_post_cache( $order_id );
199 }
200
201 /**
202 * Checks whether an HPOS-backed order is newer than the corresponding post.
203 *
204 * @param \WC_Abstract_Order $order An HPOS order.
205 * @return bool TRUE if the order is up to date with the corresponding post.
206 * @throws \Exception When the order is not an HPOS order.
207 */
208 private function is_order_newer_than_post( \WC_Abstract_Order $order ): bool {
209 if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
210 throw new \Exception( esc_html__( 'Order is not an HPOS order.', 'woocommerce' ) );
211 }
212
213 $post = get_post( $order->get_id() );
214 if ( ! $post || $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post->post_type ) {
215 return true;
216 }
217
218 $order_modified_gmt = $order->get_date_modified() ?? $order->get_date_created();
219 $order_modified_gmt = $order_modified_gmt ? $order_modified_gmt->getTimestamp() : 0;
220 $post_modified_gmt = $post->post_modified_gmt ?? $post->post_date_gmt;
221 $post_modified_gmt = ( $post_modified_gmt && '0000-00-00 00:00:00' !== $post_modified_gmt ) ? wc_string_to_timestamp( $post_modified_gmt ) : 0;
222
223 return $order_modified_gmt >= $post_modified_gmt;
224 }
225
226 /**
227 * Builds an array with properties and metadata for which HPOS and post record have different values.
228 * Given it's mostly informative nature, it doesn't perform any deep or recursive searches and operates only on top-level properties/metadata.
229 *
230 * @since 8.6.0
231 *
232 * @param int $order_id Order ID.
233 * @return array Array of [HPOS value, post value] keyed by property, for all properties where HPOS and post value differ.
234 */
235 public function get_diff_for_order( int $order_id ): array {
236 $diff = array();
237
238 $hpos_order = $this->get_order_from_datastore( $order_id, 'hpos' );
239 $cpt_order = $this->get_order_from_datastore( $order_id, 'posts' );
240
241 if ( $hpos_order->get_type() !== $cpt_order->get_type() ) {
242 $diff['type'] = array( $hpos_order->get_type(), $cpt_order->get_type() );
243 }
244
245 $hpos_meta = $this->order_meta_to_array( $hpos_order );
246 $cpt_meta = $this->order_meta_to_array( $cpt_order );
247
248 // Consider only keys for which we actually have a corresponding HPOS column or are meta.
249 $all_keys = array_unique(
250 array_diff(
251 array_merge(
252 $this->get_order_base_props(),
253 array_keys( $hpos_meta ),
254 array_keys( $cpt_meta )
255 ),
256 $this->data_synchronizer->get_ignored_order_props()
257 )
258 );
259
260 foreach ( $all_keys as $key ) {
261 $val1 = in_array( $key, $this->get_order_base_props(), true ) ? $hpos_order->{"get_$key"}() : ( $hpos_meta[ $key ] ?? null );
262 $val2 = in_array( $key, $this->get_order_base_props(), true ) ? $cpt_order->{"get_$key"}() : ( $cpt_meta[ $key ] ?? null );
263
264 // Workaround for https://github.com/woocommerce/woocommerce/issues/43126.
265 if ( ! $val2 && in_array( $key, array( '_billing_address_index', '_shipping_address_index' ), true ) ) {
266 $val2 = get_post_meta( $order_id, $key, true );
267 }
268
269 if ( $val1 != $val2 ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison,Universal.Operators.StrictComparisons.LooseNotEqual
270 $diff[ $key ] = array( $val1, $val2 );
271 }
272 }
273
274 return $diff;
275 }
276
277 /**
278 * Returns an order object as seen by either the HPOS or CPT datastores.
279 *
280 * @since 8.6.0
281 *
282 * @param int $order_id Order ID.
283 * @param string $data_store_id Datastore to use. Should be either 'hpos' or 'posts'. Defaults to 'hpos'.
284 * @return \WC_Order Order instance.
285 * @throws \Exception When an error occurs.
286 */
287 public function get_order_from_datastore( int $order_id, string $data_store_id = 'hpos' ) {
288 $data_store = ( 'hpos' === $data_store_id ) ? $this->data_store : $this->data_store->get_cpt_data_store_instance();
289
290 wp_cache_delete( \WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
291
292 // Prime caches if we can.
293 if ( method_exists( $data_store, 'prime_caches_for_orders' ) ) {
294 $data_store->prime_caches_for_orders( array( $order_id ), array() );
295 }
296
297 $order_type = wc_get_order_type( $data_store->get_order_type( $order_id ) );
298
299 if ( ! $order_type ) {
300 // translators: %d is an order ID.
301 throw new \Exception( esc_html( sprintf( __( '%d is not an order or has an invalid order type.', 'woocommerce' ), $order_id ) ) );
302 }
303
304 $classname = $order_type['class_name'];
305 /** @var \WC_Order $order */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
306 $order = new $classname();
307 $order->set_id( $order_id );
308
309 // Switch datastore if necessary.
310 $update_data_store_func = function ( $data_store ) {
311 /** @var \WC_Order $this */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
312 // Each order object contains a reference to its data store, but this reference is itself
313 // held inside of an instance of WC_Data_Store, so we create that first.
314 $data_store_wrapper = \WC_Data_Store::load( 'order' );
315
316 // Bind $data_store to our WC_Data_Store.
317 ( function ( $data_store ) {
318 /** @var \WC_Data_Store $this */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
319 $this->current_class_name = (string) get_class( $data_store );
320 $this->instance = $data_store;
321 } )->call( $data_store_wrapper, $data_store );
322
323 // Finally, update the $order object with our WC_Data_Store( $data_store ) instance.
324 $this->data_store = $data_store_wrapper;
325 };
326 $update_data_store_func->call( $order, $data_store );
327
328 // Read order (without triggering sync) -- we create our own callback instead of using `__return_false` to
329 // prevent `remove_filter()` from removing it in cases where it was already hooked by 3rd party code.
330 $prevent_sync_on_read = fn() => false;
331
332 add_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 );
333 try {
334 $data_store->read( $order );
335 } finally {
336 remove_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 );
337 }
338
339 return $order;
340 }
341
342 /**
343 * Backfills an order from/to the CPT or HPOS datastore.
344 *
345 * @since 8.7.0
346 *
347 * @param int $order_id Order ID.
348 * @param string $source_data_store Datastore to use as source. Should be either 'hpos' or 'posts'.
349 * @param string $destination_data_store Datastore to use as destination. Should be either 'hpos' or 'posts'.
350 * @param array $fields List of metakeys or order properties to limit the backfill to.
351 * @return void
352 * @throws \Exception When an error occurs.
353 */
354 public function backfill_order_to_datastore( int $order_id, string $source_data_store, string $destination_data_store, array $fields = array() ) {
355 $valid_data_stores = array( 'posts', 'hpos' );
356
357 if ( ! in_array( $source_data_store, $valid_data_stores, true ) || ! in_array( $destination_data_store, $valid_data_stores, true ) || $destination_data_store === $source_data_store ) {
358 throw new \Exception( esc_html( sprintf( 'Invalid datastore arguments: %1$s -> %2$s.', $source_data_store, $destination_data_store ) ) );
359 }
360
361 $fields = array_filter( $fields );
362 $src_order = $this->get_order_from_datastore( $order_id, $source_data_store );
363
364 // Backfill entire orders.
365 if ( ! $fields ) {
366 if ( 'posts' === $destination_data_store ) {
367 $src_order->get_data_store()->backfill_post_record( $src_order );
368 } elseif ( 'hpos' === $destination_data_store ) {
369 $this->posts_to_cot_migrator->migrate_orders( array( $src_order->get_id() ) );
370 }
371
372 return;
373 }
374
375 $this->validate_backfill_fields( $fields, $src_order );
376
377 $dest_order = $this->get_order_from_datastore( $src_order->get_id(), $destination_data_store );
378
379 if ( 'posts' === $destination_data_store ) {
380 $datastore = $this->data_store->get_cpt_data_store_instance();
381 } elseif ( 'hpos' === $destination_data_store ) {
382 $datastore = $this->data_store;
383 }
384
385 if ( ! $datastore || ! method_exists( $datastore, 'update_order_from_object' ) ) {
386 throw new \Exception( esc_html__( 'The backup datastore does not support updating orders.', 'woocommerce' ) );
387 }
388
389 // Backfill meta.
390 if ( ! empty( $fields['meta_keys'] ) ) {
391 foreach ( $fields['meta_keys'] as $meta_key ) {
392 $dest_order->delete_meta_data( $meta_key );
393
394 foreach ( $src_order->get_meta( $meta_key, false, 'edit' ) as $meta ) {
395 $dest_order->add_meta_data( $meta_key, $meta->value );
396 }
397 }
398 }
399
400 // Backfill props.
401 if ( ! empty( $fields['props'] ) ) {
402 $new_values = array_combine(
403 $fields['props'],
404 array_map(
405 fn( $prop_name ) => $src_order->{"get_{$prop_name}"}(),
406 $fields['props']
407 )
408 );
409
410 $dest_order->set_props( $new_values );
411
412 if ( 'hpos' === $destination_data_store ) {
413 $dest_order->apply_changes();
414 $limit_cb = function ( $rows, $order ) use ( $dest_order, $fields ) {
415 if ( $dest_order->get_id() === $order->get_id() ) {
416 $rows = $this->limit_hpos_update_to_props( $rows, $fields['props'] );
417 }
418
419 return $rows;
420 };
421 add_filter( 'woocommerce_orders_table_datastore_db_rows_for_order', $limit_cb, 10, 2 );
422 }
423 }
424
425 $datastore->update_order_from_object( $dest_order );
426
427 if ( 'hpos' === $destination_data_store && isset( $limit_cb ) ) {
428 remove_filter( 'woocommerce_orders_table_datastore_db_rows_for_order', $limit_cb );
429 }
430 }
431
432 /**
433 * Returns all metadata in an order object as an array.
434 *
435 * @param \WC_Order $order Order instance.
436 * @return array Array of metadata grouped by meta key.
437 */
438 private function order_meta_to_array( \WC_Order &$order ): array {
439 $result = array();
440
441 foreach ( ArrayUtil::select( $order->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD ) as &$meta ) {
442 if ( array_key_exists( $meta['key'], $result ) ) {
443 $result[ $meta['key'] ] = array( $result[ $meta['key'] ] );
444 $result[ $meta['key'] ][] = $meta['value'];
445 } else {
446 $result[ $meta['key'] ] = $meta['value'];
447 }
448 }
449
450 return $result;
451 }
452
453 /**
454 * Returns names of all order base properties supported by HPOS.
455 *
456 * @return string[] Property names.
457 */
458 private function get_order_base_props(): array {
459 $base_props = array();
460
461 foreach ( $this->data_store->get_all_order_column_mappings() as $mapping ) {
462 $base_props = array_merge( $base_props, array_column( $mapping, 'name' ) );
463 }
464
465 return $base_props;
466 }
467
468 /**
469 * Filters a set of HPOS row updates to those matching a specific set of order properties.
470 * Called via the `woocommerce_orders_table_datastore_db_rows_for_order` filter in `backfill_order_to_datastore`.
471 *
472 * @param array $rows Details for the db update.
473 * @param string[] $props Order property names.
474 * @return array
475 * @see OrdersTableDataStore::get_db_rows_for_order()
476 */
477 private function limit_hpos_update_to_props( array $rows, array $props ) {
478 // Determine HPOS columns corresponding to the props in the $props array.
479 $allowed_columns = array();
480 foreach ( $this->data_store->get_all_order_column_mappings() as &$mapping ) {
481 foreach ( $mapping as $column_name => &$column_data ) {
482 if ( ! isset( $column_data['name'] ) || ! in_array( $column_data['name'], $props, true ) ) {
483 continue;
484 }
485
486 $allowed_columns[ $column_data['name'] ] = $column_name;
487 }
488 }
489
490 foreach ( $rows as $i => &$db_update ) {
491 // Prevent accidental update of another prop by limiting columns to explicitly requested props.
492 if ( ! array_intersect_key( $db_update['data'], array_flip( $allowed_columns ) ) ) {
493 unset( $rows[ $i ] );
494 continue;
495 }
496
497 $allowed_column_names_with_ids = array_merge(
498 $allowed_columns,
499 array( 'id', 'order_id', 'address_type' )
500 );
501
502 $db_update['data'] = array_intersect_key( $db_update['data'], array_flip( $allowed_column_names_with_ids ) );
503 $db_update['format'] = array_intersect_key( $db_update['format'], array_flip( $allowed_column_names_with_ids ) );
504 }
505
506 return $rows;
507 }
508
509 /**
510 * Validates meta_keys and property names for a partial order backfill.
511 *
512 * @param array $fields An array possibly having entries with index 'meta_keys' and/or 'props',
513 * corresponding to an array of order meta keys and/or order properties.
514 * @param \WC_Abstract_Order $order The order being validated.
515 * @throws \Exception When a validation error occurs.
516 * @return void
517 */
518 private function validate_backfill_fields( array $fields, \WC_Abstract_Order $order ) {
519 if ( ! $fields ) {
520 return;
521 }
522
523 if ( ! empty( $fields['meta_keys'] ) ) {
524 $internal_meta_keys = array_unique(
525 array_merge(
526 $this->data_store->get_internal_meta_keys(),
527 $this->data_store->get_cpt_data_store_instance()->get_internal_meta_keys()
528 )
529 );
530
531 $possibly_internal_keys = array_intersect( $internal_meta_keys, $fields['meta_keys'] );
532 if ( ! empty( $possibly_internal_keys ) ) {
533 throw new \Exception(
534 esc_html(
535 sprintf(
536 // translators: %s is a comma separated list of metakey names.
537 _n(
538 '%s is an internal meta key. Use --props to set it.',
539 '%s are internal meta keys. Use --props to set them.',
540 count( $possibly_internal_keys ),
541 'woocommerce'
542 ),
543 implode( ', ', $possibly_internal_keys )
544 )
545 )
546 );
547 }
548 }
549
550 if ( ! empty( $fields['props'] ) ) {
551 $invalid_props = array_filter(
552 $fields['props'],
553 function ( $prop_name ) use ( $order ) {
554 return ! method_exists( $order, "get_{$prop_name}" );
555 }
556 );
557
558 if ( ! empty( $invalid_props ) ) {
559 throw new \Exception(
560 esc_html(
561 sprintf(
562 // translators: %s is a list of order property names.
563 _n(
564 '%s is not a valid order property.',
565 '%s are not valid order properties.',
566 count( $invalid_props ),
567 'woocommerce'
568 ),
569 implode( ', ', $invalid_props )
570 )
571 )
572 );
573 }
574 }
575 }
576 }
577