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