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 |