Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.81% covered (danger)
35.81%
82 / 229
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Report_Data_Fetcher
35.96% covered (danger)
35.96%
82 / 228
15.38% covered (danger)
15.38%
2 / 13
1803.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 fetch_comparison_data
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
182
 extract_base_params
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetch_period_data
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 request_endpoint_data
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_invalid_fields_error
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 merge_datasets
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 extract_ids_from_data
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 add_id_filter
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 make_proxy_request
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
10.64
 build_external_api_error
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
20
 normalize_response_data
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
1<?php
2/**
3 * Report Data Fetcher
4 *
5 * Fetches report data via ApiProxy and handles comparison mode.
6 *
7 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export
8 */
9
10declare( strict_types=1 );
11
12namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export;
13
14defined( 'ABSPATH' ) || exit;
15
16use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface;
17use Automattic\Jetpack\PremiumAnalytics\Reports\Export\MergeStrategy\Id_Based_Merge_Strategy;
18use Automattic\Jetpack\PremiumAnalytics\Reports\Export\MergeStrategy\Index_Based_Merge_Strategy;
19use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait;
20use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Utilities;
21use WP_Error;
22use WP_REST_Request;
23use WP_REST_Response;
24
25/**
26 * Data Fetcher class for retrieving report data.
27 *
28 * @since $$next-version$$
29 */
30class Report_Data_Fetcher {
31
32    use Logger_Trait;
33    use Utilities;
34
35    /**
36     * The index prefix for comparison data in arrays.
37     *
38     * @var string
39     */
40    const COMPARISON_INDEX_PREFIX = 'comparison_';
41
42    /**
43     * Maximum number of IDs to include in an IN filter.
44     *
45     * This limit prevents URL length issues (typical limit is 2048 chars).
46     * With an average ID length of 4 chars, 300 IDs ≈ 1200 chars plus other params.
47     *
48     * @var int
49     */
50    const MAX_ID_FILTER_COUNT = 300;
51
52    /**
53     * Constructor.
54     *
55     * @param Logger_Interface $logger The logger instance.
56     */
57    public function __construct( Logger_Interface $logger ) {
58        $this->logger = $logger;
59    }
60
61    /**
62     * Fetch report data based on parameters.
63     *
64     * @param array                           $params     Request parameters.
65     * @param Csv_Report_Controller_Interface $controller Controller for endpoint and matching field context.
66     * @return array|\WP_Error Report data array or error.
67     */
68    public function fetch( array $params, Csv_Report_Controller_Interface $controller ) {
69        // Merge controller-specific additional parameters (controller defaults first, user params override).
70        // get_additional_params() is part of the interface, so this applies to any implementation.
71        $params = array_merge( $controller->get_additional_params(), $params );
72
73        $fields = $controller->get_fields();
74        if ( ! empty( $fields ) ) {
75            $params['fields'] = $fields;
76        }
77
78        // Fetch data based on whether this is a comparison request.
79        if ( $this->is_comparison_request( $params ) ) {
80            return $this->fetch_comparison_data( $params, $controller );
81        }
82
83        return $this->fetch_period_data( $params, 'single period', $controller );
84    }
85
86    /**
87     * Fetch and merge comparison data.
88     *
89     * @param array                           $params     Request parameters.
90     * @param Csv_Report_Controller_Interface $controller Controller for endpoint and matching field context.
91     * @return array|\WP_Error Merged report data or error.
92     */
93    private function fetch_comparison_data( array $params, Csv_Report_Controller_Interface $controller ) {
94        // fetch() is public library API; the REST layer marks these required, but guard here too.
95        foreach ( array( 'from', 'to', 'compare_from', 'compare_to' ) as $required ) {
96            if ( empty( $params[ $required ] ) ) {
97                return new WP_Error(
98                    'missing_comparison_param',
99                    /* translators: %s: parameter name. */
100                    sprintf( __( 'Missing required comparison parameter: %s', 'jetpack-premium-analytics' ), $required ),
101                    array( 'status' => 400 )
102                );
103            }
104        }
105
106        // Build parameters for both periods.
107        $base_params = $this->extract_base_params( $params );
108
109        // Fetch original period data.
110        $original_params = array_merge(
111            $base_params,
112            array(
113                'from' => $params['from'],
114                'to'   => $params['to'],
115            )
116        );
117        $original_data   = $this->fetch_period_data( $original_params, 'original period', $controller );
118        if ( is_wp_error( $original_data ) ) {
119            return $original_data;
120        }
121
122        // Check if we need ID-based matching.
123        $matching_field = $controller->get_matching_field();
124
125        // Validate matching field exists in data if specified.
126        if ( $matching_field && ! empty( $original_data['data'] ) ) {
127            $first_item = $original_data['data'][0];
128            if ( ! isset( $first_item[ $matching_field ] ) ) {
129                $this->logger->log_error(
130                    sprintf(
131                        'Matching field "%s" not found in original data. Available fields: %s',
132                        $matching_field,
133                        implode( ', ', array_keys( $first_item ) )
134                    ),
135                    __METHOD__
136                );
137                // Fall back to null (index-based matching) instead of failing.
138                $matching_field = null;
139            }
140        }
141
142        // Fetch comparison period data.
143        $comparison_params = array_merge(
144            $base_params,
145            array(
146                'from' => $params['compare_from'],
147                'to'   => $params['compare_to'],
148            )
149        );
150
151        // If matching field specified, filter comparison to only original period IDs.
152        if ( $matching_field && ! empty( $original_data['data'] ) ) {
153            $ids = $this->extract_ids_from_data( $original_data['data'], $matching_field, $controller );
154            if ( ! empty( $ids ) ) {
155                // Check if ID count exceeds the maximum.
156                if ( count( $ids ) > self::MAX_ID_FILTER_COUNT ) {
157                    $this->logger->log_error(
158                        sprintf(
159                            'ID count (%d) exceeds maximum (%d) for field "%s". Skipping ID filter - comparison will fetch all data.',
160                            count( $ids ),
161                            self::MAX_ID_FILTER_COUNT,
162                            $matching_field
163                        ),
164                        __METHOD__
165                    );
166                } else {
167                    $comparison_params = $this->add_id_filter( $comparison_params, $matching_field, $ids, $controller );
168                }
169            }
170        }
171
172        $comparison_data = $this->fetch_period_data( $comparison_params, 'comparison period', $controller );
173        if ( is_wp_error( $comparison_data ) ) {
174            return $comparison_data;
175        }
176
177        // Merge the datasets.
178        $merged_data = $this->merge_datasets(
179            $original_data,
180            $comparison_data,
181            self::COMPARISON_INDEX_PREFIX,
182            $matching_field,
183            $controller
184        );
185
186        $this->logger->log_message(
187            sprintf(
188                'Fetched and merged comparison data: %d rows (matching: %s)',
189                count( $merged_data['data'] ?? array() ),
190                $matching_field ? "by $matching_field" : 'by index'
191            ),
192            __METHOD__
193        );
194
195        return $merged_data;
196    }
197
198    /**
199     * Extract base parameters (excluding date range and comparison params).
200     *
201     * @param array $params Request parameters.
202     * @return array Base parameters.
203     */
204    private function extract_base_params( array $params ): array {
205        $base_params = array( 'interval' => $params['interval'] ?? 'day' );
206
207        $excluded_params = array( 'endpoint', 'from', 'to', 'compare_from', 'compare_to' );
208
209        foreach ( $params as $key => $value ) {
210            if ( ! in_array( $key, $excluded_params, true ) ) {
211                $base_params[ $key ] = $value;
212            }
213        }
214
215        return $base_params;
216    }
217
218    /**
219     * Fetch data for a single period with error handling and logging.
220     *
221     * Checks if the controller has a custom fetch_data() method and uses that if available,
222     * otherwise falls back to the standard proxy request.
223     *
224     * @param array                           $params      Query parameters.
225     * @param string                          $period_name Human-readable period name for logging.
226     * @param Csv_Report_Controller_Interface $controller  The controller for endpoint and custom fetch.
227     * @return array|\WP_Error Report data or error.
228     */
229    private function fetch_period_data( array $params, string $period_name, Csv_Report_Controller_Interface $controller ) {
230        $endpoint = $controller->get_data_endpoint();
231
232        $response = $this->request_endpoint_data( $endpoint, $params, $controller );
233
234        // Some analytics endpoints have strict/limited `fields` enums and reject
235        // otherwise-valid requests. Retry once without `fields` to fetch full payload.
236        if (
237            isset( $params['fields'] ) &&
238            is_wp_error( $response ) &&
239            $this->is_invalid_fields_error( $response )
240        ) {
241            unset( $params['fields'] );
242            $this->logger->log_message(
243                sprintf( 'Retrying %s without `fields` parameter', $endpoint ),
244                __METHOD__
245            );
246
247            $response = $this->request_endpoint_data( $endpoint, $params, $controller );
248        }
249
250        if ( is_wp_error( $response ) ) {
251            $this->logger->log_error(
252                sprintf( 'Failed to fetch %s data: %s', $period_name, $response->get_error_message() ),
253                __METHOD__
254            );
255            return $response;
256        }
257
258        $this->logger->log_message(
259            sprintf( 'Fetched %s data: %d rows', $period_name, count( $response['data'] ?? array() ) ),
260            __METHOD__
261        );
262
263        return $response;
264    }
265
266    /**
267     * Request endpoint data using controller override when available.
268     *
269     * @param string                          $endpoint   Endpoint to request.
270     * @param array                           $params     Query parameters.
271     * @param Csv_Report_Controller_Interface $controller The active report controller.
272     * @return array|\WP_Error Response data or error.
273     */
274    private function request_endpoint_data(
275        string $endpoint,
276        array $params,
277        Csv_Report_Controller_Interface $controller
278    ) {
279        if ( method_exists( $controller, 'fetch_data' ) ) {
280            // @phan-suppress-next-line PhanUndeclaredMethod -- Optional hook, guarded by method_exists() above; not part of the interface.
281            return $controller->fetch_data( $endpoint, $params );
282        }
283
284        return $this->make_proxy_request( $endpoint, $params );
285    }
286
287    /**
288     * Check whether a response error indicates invalid `fields` parameter usage.
289     *
290     * @param WP_Error $error The response error.
291     * @return bool True when the API rejected the fields parameter.
292     */
293    private function is_invalid_fields_error( WP_Error $error ): bool {
294        $error_code = $error->get_error_code();
295        $data       = $error->get_error_data();
296
297        if (
298            'external_api_error' === $error_code
299            && is_array( $data )
300            && isset( $data['external_code'] )
301        ) {
302            $error_code = $data['external_code'];
303        }
304
305        if ( 'rest_invalid_param' !== $error_code ) {
306            return false;
307        }
308
309        return is_array( $data ) && isset( $data['params']['fields'] );
310    }
311
312    /**
313     * Merge comparison data with original data.
314     *
315     * Supports two strategies:
316     * 1. Index-based (default): Merges by position (row 0 with row 0)
317     * 2. ID-based: Merges by matching field (product_id, coupon_code, etc.)
318     *
319     * @param array                           $original_data   Original report data.
320     * @param array                           $comparison_data Comparison report data.
321     * @param string                          $prefix          Prefix for comparison keys.
322     * @param string|null                     $matching_field  Field to match on, or null for index.
323     * @param Csv_Report_Controller_Interface $controller      Controller for default values.
324     * @return array Merged data with comparison columns.
325     */
326    private function merge_datasets(
327        array $original_data,
328        array $comparison_data,
329        string $prefix,
330        ?string $matching_field,
331        Csv_Report_Controller_Interface $controller
332    ): array {
333        $original_items   = $original_data['data'] ?? array();
334        $comparison_items = $comparison_data['data'] ?? array();
335
336        // Select appropriate merge strategy based on matching field.
337        if ( $matching_field ) {
338            $strategy = new Id_Based_Merge_Strategy( $matching_field, $this->logger );
339        } else {
340            $strategy = new Index_Based_Merge_Strategy( $this->logger );
341        }
342
343        // Delegate to strategy.
344        $merged_items = $strategy->merge( $original_items, $comparison_items, $prefix, $controller );
345
346        $original_data['data'] = $merged_items;
347        return $original_data;
348    }
349
350    /**
351     * Extract IDs from data array using specified field.
352     *
353     * @param array                           $data       The data to extract IDs from.
354     * @param string                          $field      The field name containing the ID.
355     * @param Csv_Report_Controller_Interface $controller Controller to check empty row handling.
356     * @return array Array of unique IDs.
357     */
358    private function extract_ids_from_data( array $data, string $field, Csv_Report_Controller_Interface $controller ): array {
359        $ids = array();
360        foreach ( $data as $item ) {
361            if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) {
362                $ids[] = $item[ $field ];
363            } elseif ( $controller->should_include_empty_rows() ) {
364                // Include a placeholder for empty values if controller includes empty rows.
365                // This ensures comparison data is fetched for empty rows.
366                $ids[] = '';
367            }
368        }
369        return array_unique( $ids );
370    }
371
372    /**
373     * Add an IN filter to params for matching specific IDs.
374     *
375     * Supports two formats based on controller preference:
376     * - Array format: filters[0][value][]=id1&filters[0][value][]=id2 (for order-attribution endpoints)
377     * - Comma format: filters[0][value]=id1,id2 (default, more URL-efficient)
378     *
379     * Note: Caller should ensure ID count doesn't exceed MAX_ID_FILTER_COUNT to avoid
380     * URL length issues (typically 2048 chars).
381     *
382     * @param array                           $params     Parameters array.
383     * @param string                          $field      Field name to filter on.
384     * @param array                           $ids        Array of IDs to include.
385     * @param Csv_Report_Controller_Interface $controller Controller for format preference.
386     * @return array Modified params with filter added.
387     */
388    private function add_id_filter( array $params, string $field, array $ids, Csv_Report_Controller_Interface $controller ): array {
389        // Find next available filter index.
390        $filter_index = 0;
391
392        // Check both flat keys and nested array structure for existing filters.
393        foreach ( array_keys( $params ) as $key ) {
394            if ( preg_match( '/^filters\[(\d+)\]/', $key, $matches ) ) {
395                $filter_index = max( $filter_index, (int) $matches[1] + 1 );
396            }
397        }
398        if ( isset( $params['filters'] ) && is_array( $params['filters'] ) ) {
399            $filter_index = max( $filter_index, count( $params['filters'] ) );
400        }
401
402        // Build filter as nested array structure.
403        if ( ! isset( $params['filters'] ) ) {
404            $params['filters'] = array();
405        }
406
407        // Add values in controller's preferred format.
408        if ( $controller->use_array_filter_format() ) {
409            // Array format: pass IDs as an array so each serializes to its own filter value entry.
410            $params['filters'][ $filter_index ] = array(
411                'key'     => $field,
412                'compare' => 'IN',
413                'value'   => $ids,
414            );
415        } else {
416            // Comma-separated format (default, more URL-efficient).
417            $params['filters'][ $filter_index ] = array(
418                'key'     => $field,
419                'compare' => 'IN',
420                'value'   => implode( ',', $ids ),
421            );
422        }
423
424        return $params;
425    }
426
427    /**
428     * Make an internal REST API call to the ApiProxy endpoint.
429     *
430     * @param string $endpoint The endpoint to call (e.g., 'reports/orders/by-date').
431     * @param array  $params   Query parameters.
432     * @return array|\WP_Error The response data or error.
433     */
434    protected function make_proxy_request( string $endpoint, array $params ) {
435        // Re-pointed from WooCommerce Analytics' own /wc/v3/<slug>/proxy route to Premium
436        // Analytics' existing data proxy, which forwards the `analytics` prefix to the WPCOM
437        // analytics API (v2 base). The endpoint lives in the route path.
438        $proxy_route = sprintf( '/jetpack-premium-analytics/v1/proxy/v2/analytics/%s', $endpoint );
439
440        // Remaining params are forwarded as query args. They must be set as query params (the
441        // proxy reads get_query_params()), not appended to the route string, or they would
442        // pollute the captured `endpoint` path segment and fail its validation.
443        unset( $params['endpoint'] );
444
445        $request = new WP_REST_Request( 'GET', $proxy_route );
446        $request->set_query_params( $params );
447
448        // Make internal REST API call. rest_do_request() always returns a WP_REST_Response
449        // (never a WP_Error); proxy failures surface via $response->is_error() below.
450        $response = rest_do_request( $request );
451
452        // Check for errors.
453        if ( $response->is_error() ) {
454            $error_data = $this->build_external_api_error( $response );
455            $error_meta = $error_data->get_error_data();
456            $message    = is_array( $error_meta ) && ! empty( $error_meta['message'] )
457                ? $error_meta['message']
458                : $error_data->get_error_message();
459
460            $this->logger->log_error(
461                'Proxy request failed: ' . $message,
462                __METHOD__
463            );
464            return $error_data;
465        }
466
467        // Get response data and fully normalize to associative arrays. A top-level object OR a
468        // top-level list whose items are stdClass both need converting, otherwise stdClass rows
469        // would reach format_row_with_comparison( array $item ) and throw a TypeError.
470        $data = $this->normalize_response_data( $response->get_data() );
471        if ( is_wp_error( $data ) ) {
472            return $data;
473        }
474
475        // Normalize response structure: some endpoints return 'items' instead of 'data'.
476        if ( isset( $data['items'] ) && ! isset( $data['data'] ) ) {
477            $data['data'] = $data['items'];
478            unset( $data['items'] );
479        }
480
481        // Check if the response has error status (API returned error).
482        if (
483            isset( $data['data']['status'] )
484            && is_numeric( $data['data']['status'] )
485            && (int) $data['data']['status'] >= 400
486        ) {
487            return $this->build_external_api_error( $response );
488        }
489
490        return $data;
491    }
492
493    /**
494     * Build a stable local error from an external API error response.
495     *
496     * WP_REST_Response::as_error() can lose the upstream message for proxied error
497     * payloads represented as stdClass. Preserve the external details in data while
498     * keeping a consistent local error message for the CSV export route.
499     *
500     * @since $$next-version$$
501     *
502     * @param WP_REST_Response $response Response containing an external API error.
503     * @return WP_Error Normalized external API error.
504     */
505    private function build_external_api_error( WP_REST_Response $response ): WP_Error {
506        $data = $this->normalize_response_data( $response->get_data() );
507        if ( is_wp_error( $data ) ) {
508            return $data;
509        }
510
511        $response_status  = (int) $response->get_status();
512        $status           = $response_status >= 400 ? $response_status : 500;
513        $external_code    = null;
514        $external_message = null;
515        $external_params  = null;
516
517        if ( is_array( $data ) ) {
518            $external_data = isset( $data['data'] ) && is_array( $data['data'] )
519                ? $data['data']
520                : array();
521
522            // A real HTTP error status is authoritative. Only use an embedded status when the
523            // transport succeeded but the response body represents an API failure.
524            if (
525                $response_status < 400
526                && isset( $external_data['status'] )
527                && is_numeric( $external_data['status'] )
528                && (int) $external_data['status'] >= 400
529            ) {
530                $status = (int) $external_data['status'];
531            }
532
533            if ( isset( $data['code'] ) && is_scalar( $data['code'] ) ) {
534                $external_code = (string) $data['code'];
535            }
536
537            if ( isset( $data['message'] ) && is_scalar( $data['message'] ) ) {
538                $external_message = (string) $data['message'];
539            }
540
541            if ( isset( $external_data['params'] ) && is_array( $external_data['params'] ) ) {
542                $external_params = $external_data['params'];
543            }
544        }
545
546        $error_data = array(
547            'status' => $status > 0 ? $status : 500,
548        );
549
550        if ( null !== $external_message ) {
551            $error_data['message'] = $external_message;
552        }
553
554        if ( null !== $external_code ) {
555            $error_data['external_code'] = $external_code;
556        }
557
558        if ( null !== $external_params ) {
559            $error_data['params'] = $external_params;
560        }
561
562        return new WP_Error(
563            'external_api_error',
564            __( 'External API error', 'jetpack-premium-analytics' ),
565            $error_data
566        );
567    }
568
569    /**
570     * Normalize REST response data to associative arrays.
571     *
572     * @param mixed $data Response data.
573     * @return mixed|WP_Error Normalized response data or an error when it cannot be encoded.
574     */
575    private function normalize_response_data( $data ) {
576        if ( ! is_object( $data ) && ! is_array( $data ) ) {
577            return $data;
578        }
579
580        $encoded = wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
581        if ( false === $encoded ) {
582            $this->logger->log_error( 'Failed to JSON encode proxy response data: ' . json_last_error_msg(), __METHOD__ );
583            return new WP_Error(
584                'proxy_response_encode_failed',
585                __( 'Failed to normalize proxy response data.', 'jetpack-premium-analytics' )
586            );
587        }
588
589        return json_decode( $encoded, true );
590    }
591}