Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 178
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerce_Products
0.00% covered (danger)
0.00%
0 / 178
0.00% covered (danger)
0.00%
0 / 24
3660
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 table
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 id_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 full_sync_action_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_listeners
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 init_full_sync_listeners
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_before_send
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 action_wp_delete_post
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 action_wp_trash_post
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 expand_product_data
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 get_objects_by_id
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 get_product_by_ids
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
110
 build_full_sync_action_array
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 get_next_chunk
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 get_product_meta_data
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 get_product_posts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 get_product_cogs_data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 get_product_types
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 datetime_to_object
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 is_a_product_post
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * WooCommerce Products sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use DateTimeZone;
11use WC_DateTime;
12use WP_Error;
13
14/**
15 * Class to handle sync for WooCommerce Products table.
16 *
17 * Note: This module is currently used for analytics purposes only.
18 */
19class WooCommerce_Products extends Module {
20
21    const PRODUCT_POST_TYPES = array( 'product', 'product_variation' );
22
23    /**
24     * Constructor.
25     */
26    public function __construct() {
27        _deprecated_class( 'WooCommerce_Products', '4.24.0', 'Automattic\Jetpack\Sync\Modules\Posts' );
28        // Preprocess action to be sent by Jetpack sync for wp_delete_post.
29        add_action( 'delete_post', array( $this, 'action_wp_delete_post' ), 10, 1 );
30        add_action( 'trashed_post', array( $this, 'action_wp_trash_post' ), 10, 1 );
31    }
32
33    /**
34     * Sync module name.
35     *
36     * @access public
37     *
38     * @return string
39     */
40    public function name() {
41        return 'woocommerce_products';
42    }
43
44    /**
45     * The table in the database with the prefix.
46     *
47     * @access public
48     *
49     * @return string|bool
50     */
51    public function table() {
52        global $wpdb;
53        return $wpdb->prefix . 'wc_product_meta_lookup';
54    }
55
56    /**
57     * The id field in the database.
58     *
59     * @access public
60     *
61     * @return string
62     */
63    public function id_field() {
64        return 'product_id';
65    }
66
67    /**
68     * The full sync action name for this module.
69     *
70     * @access public
71     *
72     * @return string
73     */
74    public function full_sync_action_name() {
75        return 'jetpack_full_sync_woocommerce_products';
76    }
77
78    /**
79     * Initialize WooCommerce Products action listeners.
80     *
81     * @access public
82     *
83     * @param callable $callable Action handler callable.
84     */
85    public function init_listeners( $callable ) {
86        // Listen to product creation and updates - these hooks trigger products table updates
87        add_action( 'woocommerce_new_product', $callable, 10, 1 );
88        add_action( 'woocommerce_update_product', $callable, 10, 1 );
89
90        // Listen to variation creation and updates (they also affect products table)
91        add_action( 'woocommerce_new_product_variation', $callable, 10, 1 );
92        add_action( 'woocommerce_update_product_variation', $callable, 10, 1 );
93
94        // Listen to specific stock update.
95        add_action( 'woocommerce_updated_product_stock', $callable, 10, 1 );
96
97        // Listen to product trashed.
98        add_action( 'jetpack_sync_woocommerce_product_trashed', $callable, 10, 1 );
99
100        // Listen to product deletion via wp_delete_post (more reliable than WC hooks)
101        add_action( 'jetpack_sync_woocommerce_product_deleted', $callable, 10, 1 );
102
103        // Add filters to expand product data before sync
104        add_filter( 'jetpack_sync_before_enqueue_woocommerce_new_product', array( $this, 'expand_product_data' ) );
105        add_filter( 'jetpack_sync_before_enqueue_woocommerce_update_product', array( $this, 'expand_product_data' ) );
106        add_filter( 'jetpack_sync_before_enqueue_woocommerce_new_product_variation', array( $this, 'expand_product_data' ) );
107        add_filter( 'jetpack_sync_before_enqueue_woocommerce_update_product_variation', array( $this, 'expand_product_data' ) );
108        add_filter( 'jetpack_sync_before_enqueue_woocommerce_updated_product_stock', array( $this, 'expand_product_data' ) );
109        add_filter( 'jetpack_sync_before_enqueue_jetpack_sync_woocommerce_product_trashed', array( $this, 'expand_product_data' ) );
110    }
111
112    /**
113     * Initialize WooCommerce Products action listeners for full sync.
114     *
115     * @access public
116     *
117     * @param callable $callable Action handler callable.
118     */
119    public function init_full_sync_listeners( $callable ) {
120        add_action( 'jetpack_full_sync_woocommerce_products', $callable );
121    }
122
123    /**
124     * Retrieve the actions that will be sent for this module during a full sync.
125     *
126     * @access public
127     *
128     * @return array Full sync actions of this module.
129     */
130    public function get_full_sync_actions() {
131        return array( 'jetpack_full_sync_woocommerce_products' );
132    }
133
134    /**
135     * Initialize the module in the sender.
136     *
137     * @access public
138     */
139    public function init_before_send() {
140        // Full sync.
141        add_filter( 'jetpack_sync_before_send_jetpack_full_sync_woocommerce_products', array( $this, 'build_full_sync_action_array' ) );
142    }
143
144    /**
145     * Handle wp_delete_post action and trigger custom product deletion sync for WooCommerce products.
146     *
147     * @param int $post_id The post ID being deleted.
148     */
149    public function action_wp_delete_post( $post_id ) {
150        if ( $this->is_a_product_post( $post_id ) ) {
151            /**
152             * Fires when a WooCommerce product is deleted via wp_delete_post.
153             *
154             * @param int $post_id The product ID being deleted.
155             */
156            do_action( 'jetpack_sync_woocommerce_product_deleted', $post_id );
157        }
158    }
159
160    /**
161     * Handle wp_trash_post action and trigger custom product trashed sync for WooCommerce products.
162     *
163     * @param int $post_id The post ID being trashed.
164     */
165    public function action_wp_trash_post( $post_id ) {
166        if ( $this->is_a_product_post( $post_id ) ) {
167            /**
168             * Fires when a WooCommerce product is trashed via wp_trash_post.
169             *
170             * @param int $post_id The product ID being trashed.
171             */
172            do_action( 'jetpack_sync_woocommerce_product_trashed', $post_id );
173        }
174    }
175
176    /**
177     * Expand product data to include products table information.
178     *
179     * @param array $args The hook arguments.
180     * @return array $args The hook arguments with expanded data.
181     */
182    public function expand_product_data( $args ) {
183        if ( empty( $args[0] ) ) {
184            return $args;
185        }
186
187        $product_id = $args[0];
188
189        // Get the product data
190        $product_data = $this->get_product_by_ids( array( $product_id ) );
191
192        if ( ! empty( $product_data ) ) {
193            $args[1] = reset( $product_data ); // Get the first (and only) result
194        }
195
196        return $args;
197    }
198
199    /**
200     * Enqueue the WooCommerce Products actions for full sync.
201     *
202     * @access public
203     *
204     * @param array   $config               Full sync configuration for this sync module.
205     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
206     * @param boolean $state                True if full sync has finished enqueueing this module, false otherwise.
207     * @return array Number of actions enqueued, and next module state.
208     */
209    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
210        return $this->enqueue_all_ids_as_action(
211            'jetpack_full_sync_woocommerce_products',
212            $this->table(),
213            'product_id',
214            $this->get_where_sql( $config ),
215            $max_items_to_enqueue,
216            $state
217        );
218    }
219
220    /**
221     * Retrieve an estimated number of actions that will be enqueued.
222     *
223     * @access public
224     *
225     * @param array $config Full sync configuration for this sync module.
226     * @return int Number of items yet to be enqueued.
227     */
228    public function estimate_full_sync_actions( $config ) {
229        global $wpdb;
230
231        $query = "SELECT count(*) FROM {$this->table()} WHERE " . $this->get_where_sql( $config );
232        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
233        $count = (int) $wpdb->get_var( $query );
234
235        return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
236    }
237
238    /**
239     * Return a list of objects by their type and IDs
240     *
241     * @param string $object_type Object type.
242     * @param array  $ids IDs of objects to return.
243     *
244     * @access public
245     *
246     * @return array|object|WP_Error|null
247     */
248    public function get_objects_by_id( $object_type, $ids ) {
249        if ( 'product' !== $object_type || empty( $ids ) || ! is_array( $ids ) ) {
250            return array();
251        }
252
253        return $this->get_product_by_ids( $ids );
254    }
255
256    /**
257     * Returns a list of product objects by their IDs.
258     *
259     * @param array  $ids List of product IDs to fetch.
260     * @param string $order Either 'ASC' or 'DESC'.
261     *
262     * @access public
263     *
264     * @return array|object|null
265     */
266    public function get_product_by_ids( $ids, $order = '' ) {
267        if ( ! is_array( $ids ) ) {
268            return array();
269        }
270
271        // Make sure the IDs are numeric and are non-zero.
272        $ids = array_filter( array_map( 'intval', $ids ) );
273
274        if ( empty( $ids ) ) {
275            return array();
276        }
277
278        $posts         = $this->get_product_posts( $ids, $order );
279        $product_types = $this->get_product_types( $ids, $order );
280
281        $products = array();
282
283        // Build base product data from posts.
284        foreach ( $posts as $post ) {
285            $products[ $post->ID ] = array(
286                'product_id'    => $post->ID,
287                'title'         => $post->post_title,
288                'post_status'   => $post->post_status,
289                'slug'          => $post->post_name,
290                'date_created'  => $this->datetime_to_object( $post->post_date ),
291                'date_modified' => $this->datetime_to_object( $post->post_modified ),
292            );
293
294            $post_type = $post->post_type;
295            // ProductType::VARIATION and ProductType::SIMPLE have only existed since WooCommerce 9.7, so
296            // we can't rely on that existing, but using the strings is probably safe enough.
297            if ( 'product_variation' === $post_type ) {
298                $product_type = 'variation';
299            } elseif ( 'product' === $post_type ) {
300                $product_type = $product_types[ $post->ID ] ?? 'simple';
301            } else {
302                $product_type = null;
303            }
304            $products[ $post->ID ]['type'] = $product_type;
305        }
306
307        // Merge in product meta data.
308        $product_meta_data = $this->get_product_meta_data( $ids, $order );
309        foreach ( $product_meta_data as $meta ) {
310            $product_id = $meta['product_id'];
311            if ( isset( $products[ $product_id ] ) ) {
312                $products[ $product_id ] = array_merge( $products[ $product_id ], $meta );
313            } else {
314                $products[ $product_id ] = $meta;
315            }
316        }
317
318        // Add COGS data.
319        $cogs_data = $this->get_product_cogs_data( $ids, $order );
320        foreach ( $cogs_data as $product_id => $cogs_value ) {
321            if ( ! isset( $products[ $product_id ] ) ) {
322                $products[ $product_id ] = array();
323            }
324            $products[ $product_id ]['cogs_amount'] = $cogs_value;
325        }
326
327        return $products;
328    }
329
330    /**
331     * Build the full sync action object for WooCommerce products.
332     *
333     * @access public
334     *
335     * @param array $args An array with the product data and the previous end.
336     *
337     * @return array An array with the product data and the previous end.
338     */
339    public function build_full_sync_action_array( $args ) {
340        list( $filtered_product, $previous_end ) = $args;
341        return array(
342            'product'      => $filtered_product['objects'],
343            'previous_end' => $previous_end,
344        );
345    }
346
347    /**
348     * Given the Module Configuration and Status return the next chunk of items to send.
349     *
350     * @param array $config This module Full Sync configuration.
351     * @param array $status This module Full Sync status.
352     * @param int   $chunk_size Chunk size.
353     *
354     * @return array
355     */
356    public function get_next_chunk( $config, $status, $chunk_size ) {
357        $product_ids = parent::get_next_chunk( $config, $status, $chunk_size );
358
359        if ( empty( $product_ids ) ) {
360            return array();
361        }
362
363        // Fetch the product data in DESC order for the next chunk logic to work.
364        $product_data = $this->get_product_by_ids( $product_ids, 'DESC' );
365
366        // If no data was fetched, make sure to return the expected structure so that status is updated correctly.
367        if ( empty( $product_data ) ) {
368            return array(
369                'object_ids' => $product_ids,
370                'objects'    => array(),
371            );
372        }
373        // Filter the product data based on the maximum size constraints.
374        // We don't have separate metadata, so we pass empty array for metadata.
375        list( $filtered_product_ids, $filtered_product_data, ) = $this->filter_objects_and_metadata_by_size(
376            'product',
377            $product_data,
378            array(), // No separate metadata for products table
379            0,       // No individual meta size limit since we don't have separate metadata
380            self::MAX_SIZE_FULL_SYNC
381        );
382
383        return array(
384            'object_ids' => $filtered_product_ids,
385            'objects'    => $filtered_product_data,
386        );
387    }
388
389    /**
390     * Get the product meta data from the product meta lookup table.
391     *
392     * @param array  $ids List of product IDs to fetch.
393     * @param string $order Either 'ASC' or 'DESC'.
394     *
395     * @return array
396     */
397    private function get_product_meta_data( $ids, $order = '' ) {
398        global $wpdb;
399
400        // Prepare the placeholders for the prepared query below.
401        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
402
403        $query = "SELECT * FROM {$this->table()} WHERE product_id IN ( $placeholders )";
404        if ( ! empty( $order ) && in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
405            $query .= " ORDER BY product_id $order";
406        }
407
408        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Hardcoded query, no user variable
409        $product_meta_data = $wpdb->get_results( $wpdb->prepare( $query, $ids ), ARRAY_A );
410
411        if ( ! is_array( $product_meta_data ) ) {
412            return array();
413        }
414
415        return $product_meta_data;
416    }
417
418    /**
419     * Get the product data from the posts table.
420     *
421     * @param array  $ids List of product IDs to fetch.
422     * @param string $order Either 'ASC' or 'DESC'.
423     *
424     * @return array
425     */
426    private function get_product_posts( $ids, $order = '' ) {
427        $posts = get_posts(
428            array(
429                'include'     => $ids,
430                'order'       => $order,
431                'post_type'   => self::PRODUCT_POST_TYPES,
432                'post_status' => array( 'any', 'trash', 'auto-draft' ),
433                'numberposts' => -1, // Get all posts.
434            )
435        );
436
437        return $posts;
438    }
439
440    /**
441     * Get the product cogs data from the product meta lookup table.
442     *
443     * @param array  $ids List of product IDs to fetch.
444     * @param string $order Either 'ASC' or 'DESC'.
445     *
446     * @return array
447     */
448    private function get_product_cogs_data( $ids, $order = '' ) {
449        // @phan-suppress-current-line UnusedPluginSuppression @phan-suppress-next-line PhanUndeclaredClassMethod -- we're checking for the class (around since WooCommerce 7.1) before calling the method (introduced as part of the original class). See also: https://github.com/phan/phan/issues/1204
450        $is_cogs_enabled = class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) && \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
451
452        if ( ! $is_cogs_enabled ) {
453            return array();
454        }
455
456        global $wpdb;
457
458        // Prepare the placeholders for the prepared query below.
459        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
460
461        $query = "
462          SELECT post_id, meta_value
463          FROM {$wpdb->postmeta}
464          WHERE post_id IN ( $placeholders )
465          AND meta_key = '_cogs_total_value'
466      ";
467
468        if ( ! empty( $order ) && in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
469            $query .= " ORDER BY post_id $order";
470        }
471
472        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Hardcoded query, no user variable
473        $results = $wpdb->get_results( $wpdb->prepare( $query, $ids ), ARRAY_A );
474
475        if ( ! is_array( $results ) ) {
476            return array();
477        }
478
479        $product_cogs_data = array();
480        foreach ( $results as $result ) {
481            $cogs_value                              = '' === $result['meta_value'] ? null : (float) $result['meta_value'];
482            $product_cogs_data[ $result['post_id'] ] = $cogs_value;
483        }
484
485        return $product_cogs_data;
486    }
487
488    /**
489     * Get product types for multiple product IDs in bulk.
490     *
491     * @param array  $ids List of product IDs to fetch types for.
492     * @param string $order Either 'ASC' or 'DESC'.
493     *
494     * @return array Array of product_id => product_type mapping.
495     */
496    private function get_product_types( $ids, $order = '' ) {
497        if ( empty( $ids ) ) {
498            return array();
499        }
500
501        global $wpdb;
502
503        // Bulk load term relationships and term data
504        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
505        $query        = "
506            SELECT tr.object_id, t.name
507            FROM {$wpdb->term_relationships} tr
508            INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
509            INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
510            WHERE tr.object_id IN ( $placeholders )
511            AND tt.taxonomy = 'product_type'
512        ";
513
514        if ( ! empty( $order ) && in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
515            $query .= " ORDER BY tr.object_id $order";
516        }
517
518        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Hardcoded query, no user variable
519        $results = $wpdb->get_results( $wpdb->prepare( $query, $ids ) );
520
521        if ( ! is_array( $results ) ) {
522            return array();
523        }
524
525        $product_types = array();
526        foreach ( $results as $result ) {
527            $product_types[ $result->object_id ] = sanitize_title( $result->name );
528        }
529
530        return $product_types;
531    }
532
533    /**
534     * Convert the WC_DateTime objects to stdClass objects to ensure they are properly encoded.
535     *
536     * @param WC_DateTime|mixed $wc_datetime The datetime object.
537     * @param bool              $utc         Whether to convert to UTC.
538     * @return object|null
539     */
540    private function datetime_to_object( $wc_datetime, $utc = false ) {
541        if ( is_string( $wc_datetime ) ) {
542            $wc_datetime = new WC_DateTime( $wc_datetime, new DateTimeZone( wc_timezone_string() ) );
543        }
544
545        if ( is_a( $wc_datetime, 'WC_DateTime' ) ) {
546            if ( $utc ) {
547                $wc_datetime->setTimezone( new DateTimeZone( 'UTC' ) );
548            } else {
549                $wc_datetime->setTimezone( new DateTimeZone( wc_timezone_string() ) );
550            }
551            return (object) (array) $wc_datetime;
552        }
553
554        return null;
555    }
556
557    /**
558     * Check if the post is a product post.
559     *
560     * @param int $post_id The post ID to check.
561     * @return bool True if the post is a product post, false otherwise.
562     */
563    private function is_a_product_post( $post_id ) {
564        $post_type = get_post_type( $post_id );
565        return in_array( $post_type, self::PRODUCT_POST_TYPES, true );
566    }
567}