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