Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.28% covered (warning)
71.28%
67 / 94
52.38% covered (warning)
52.38%
11 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Csv_Report_Controller
72.04% covered (warning)
72.04%
67 / 93
52.38% covered (warning)
52.38%
11 / 21
107.83
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
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 format_row_with_comparison
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_report_key
n/a
0 / 0
n/a
0 / 0
0
 get_report_label
n/a
0 / 0
n/a
0 / 0
0
 get_data_endpoint
n/a
0 / 0
n/a
0 / 0
0
 get_column_headers
n/a
0 / 0
n/a
0 / 0
0
 format_row_for_csv
n/a
0 / 0
n/a
0 / 0
0
 get_default_values
n/a
0 / 0
n/a
0 / 0
0
 get_batch_limit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_additional_params
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_matching_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_identifying_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 use_array_filter_format
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_include_empty_rows_by_default
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_include_empty_rows
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_empty_row_label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_empty_row_check_field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format_time_interval
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 format_amount
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_interval_label
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 extract_data_by_prefix
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 format_row_with_empty_handling
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
20.42
 is_row_empty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 apply_empty_row_label
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 add_comparison_fields
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Abstract CSV Report Controller
4 *
5 * Base class for CSV export report controllers. Provides common functionality
6 * for formatting and exporting report data to CSV via the new Report_Registry system.
7 *
8 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export
9 */
10
11declare( strict_types=1 );
12
13namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export;
14
15defined( 'ABSPATH' ) || exit;
16
17use DateTime;
18use Exception;
19
20/**
21 * Abstract base class for CSV report controllers.
22 *
23 * All report controllers extend this base, which centralizes comparison-period
24 * handling, empty-row logic, requested-field selection, and value formatting.
25 *
26 * Each concrete controller should implement:
27 * - get_report_key(): Unique identifier for the report
28 * - get_report_label(): Human-readable name
29 * - get_data_endpoint(): API endpoint to fetch data from
30 * - get_column_headers(): CSV column headers
31 * - format_row_for_csv(): Transform raw data row to CSV format
32 *
33 * Optional overrides:
34 * - get_batch_limit(): Maximum number of items per batch (defaults to DEFAULT_BATCH_LIMIT)
35 * - get_additional_params(): Additional parameters to include in data requests (e.g., filters)
36 *
37 * The parent class handles:
38 * - Auto-registration with Report_Registry in constructor
39 * - Automatic comparison field handling via format_row_with_comparison()
40 * - Helper methods for common formatting tasks
41 *
42 * @since $$next-version$$
43 */
44abstract class Abstract_Csv_Report_Controller implements Csv_Report_Controller_Interface {
45
46    /**
47     * Default batch limit for time-based reports (1000 items).
48     */
49    protected const DEFAULT_BATCH_LIMIT = 1000;
50
51    /**
52     * Default date type for order-based reports (creation date).
53     */
54    protected const DEFAULT_DATE_TYPE = 'created';
55
56    /**
57     * Report registry instance.
58     *
59     * @var Report_Registry
60     */
61    protected $registry;
62
63    /**
64     * Cached result of should_include_empty_rows() to avoid repeated filter calls.
65     *
66     * @var bool|null Null if not yet determined, otherwise the cached boolean result.
67     */
68    private $cached_include_empty_rows = null;
69
70    /**
71     * Constructor.
72     *
73     * @param Report_Registry $registry Registry instance (injected by DI).
74     */
75    public function __construct( Report_Registry $registry ) {
76        $this->registry = $registry;
77    }
78
79    /**
80     * Register this controller with the report registry.
81     *
82     * @return void
83     */
84    public function register(): void {
85        $this->registry->register_controller( $this );
86    }
87
88    /**
89     * Format a row with automatic comparison field handling.
90     *
91     * This method wraps format_row_for_csv() and automatically adds
92     * comparison fields if present in the data.
93     *
94     * @param array       $item     The raw data item.
95     * @param string|null $interval Optional time interval for formatting.
96     * @return array The formatted row with comparison fields.
97     */
98    public function format_row_with_comparison( array $item, ?string $interval = null ): array {
99        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
100
101        // Extract original data (fields without comparison_ prefix) to check for empty values.
102        // Note: We format the full $item, not the extracted subset, to preserve all data.
103        $original_data = $this->extract_data_by_prefix( $item, $prefix, false );
104        $row           = $this->format_row_with_empty_handling( $original_data, $item, $interval );
105
106        return $this->add_comparison_fields( $row, $item, $interval );
107    }
108
109    // ============================================================================
110    // Abstract Methods - Must be implemented by child classes
111    // ============================================================================
112
113    /**
114     * Get the report key (unique identifier).
115     *
116     * @return string
117     */
118    abstract public function get_report_key(): string;
119
120    /**
121     * Get the report label (human-readable name).
122     *
123     * @return string
124     */
125    abstract public function get_report_label(): string;
126
127    /**
128     * Get the data endpoint (API route).
129     *
130     * @return string
131     */
132    abstract public function get_data_endpoint(): string;
133
134    /**
135     * Get the column headers for CSV export.
136     *
137     * @param string|null $interval Optional time interval for dynamic headers.
138     * @return array
139     */
140    abstract public function get_column_headers( ?string $interval = null ): array;
141
142    /**
143     * Format a single data item for CSV export.
144     *
145     * This method should return the base row data without comparison fields.
146     * Comparison fields are automatically added by format_row_with_comparison().
147     *
148     * @param array       $item     The raw data item.
149     * @param string|null $interval Optional time interval for formatting.
150     * @return array The formatted row for CSV.
151     */
152    abstract public function format_row_for_csv( array $item, ?string $interval = null ): array;
153
154    /**
155     * Get default values for missing data fields.
156     *
157     * This method should return an array of default values for all possible fields
158     * in this report. Used when creating empty items for missing comparison data.
159     *
160     * @return array Array of field_name => default_value pairs.
161     */
162    abstract public function get_default_values(): array;
163
164    /**
165     * Get the batch limit (max items per request).
166     *
167     * Override this method in child classes if a different batch limit is needed.
168     *
169     * @return int
170     */
171    public function get_batch_limit(): int {
172        return self::DEFAULT_BATCH_LIMIT;
173    }
174
175    /**
176     * Get additional request parameters for data fetching.
177     *
178     * Override this method in child classes to add controller-specific parameters
179     * (e.g., filters) that should be included in every data fetch request.
180     *
181     * @return array Additional parameters to include in data requests.
182     */
183    public function get_additional_params(): array {
184        return array();
185    }
186
187    /**
188     * Get the list of fields to request from the API.
189     *
190     * Override in subclasses to request only the fields needed for
191     * this report, reducing API response payload size.
192     * Return an empty array to request all fields (default behavior).
193     *
194     * @return array Field names to request, or empty array for all fields.
195     */
196    public function get_fields(): array {
197        return array();
198    }
199
200    /**
201     * Get the matching field for comparison data alignment.
202     *
203     * Default: null (index-based matching for time-series reports).
204     * Override in child classes for ID-based matching (ranked reports).
205     *
206     * @return string|null
207     */
208    public function get_matching_field(): ?string {
209        return null;
210    }
211
212    /**
213     * Get the identifying fields that should be preserved in comparison data.
214     *
215     * Default: empty array (no fields preserved for time-series reports).
216     * Override in ranked report controllers to specify identifying fields like
217     * 'product_name' or 'coupon_code' that should be copied when comparison data is missing.
218     *
219     * @return array Array of field names to preserve.
220     */
221    public function get_identifying_fields(): array {
222        return array();
223    }
224
225    /**
226     * Whether to use array format for filter values in IN filters.
227     *
228     * Default: false (comma-separated format for URL efficiency).
229     * Override in controllers that require array format (e.g., order-attribution).
230     *
231     * @return bool True to use array format, false for comma-separated (default).
232     */
233    public function use_array_filter_format(): bool {
234        return false;
235    }
236
237    /**
238     * Whether to include empty rows by default.
239     *
240     * Default: true (include rows with empty identifying fields).
241     * Override in child controllers to return false if empty rows should be excluded by default.
242     *
243     * @return bool True to include empty rows, false to exclude them.
244     */
245    protected function should_include_empty_rows_by_default(): bool {
246        return true;
247    }
248
249    /**
250     * Whether to include rows with empty identifying fields in the export.
251     *
252     * Calls should_include_empty_rows_by_default() and applies a filter for global control.
253     * Filterable via 'jetpack_premium_analytics_csv_include_empty_rows' to globally
254     * control empty row inclusion across all reports or specific reports.
255     *
256     * The result is cached per controller instance to avoid repeated filter calls
257     * when processing large datasets.
258     *
259     * @return bool True to include empty rows with custom label, false to skip them.
260     */
261    public function should_include_empty_rows(): bool {
262        // Return cached value if already determined.
263        if ( null !== $this->cached_include_empty_rows ) {
264            return $this->cached_include_empty_rows;
265        }
266
267        $default = $this->should_include_empty_rows_by_default();
268
269        /**
270         * Filter whether to include empty rows in CSV exports.
271         *
272         * This filter takes precedence over controller defaults, allowing global control.
273         *
274         * @param bool   $include_empty Whether to include empty rows (controller default).
275         * @param string $report_key    The report key for this controller.
276         * @param object $controller    The controller instance.
277         */
278        $this->cached_include_empty_rows = apply_filters( 'jetpack_premium_analytics_csv_include_empty_rows', $default, $this->get_report_key(), $this );
279
280        return $this->cached_include_empty_rows;
281    }
282
283    /**
284     * Get the label to use for rows with empty identifying fields.
285     *
286     * Only used when should_include_empty_rows() returns true.
287     * Override in child controllers to customize the label for empty rows.
288     *
289     * @return string The label to use for empty rows.
290     */
291    public function get_empty_row_label(): string {
292        return __( 'Unassigned', 'jetpack-premium-analytics' );
293    }
294
295    /**
296     * Get the field name(s) to check for emptiness when determining if a row should be skipped.
297     *
298     * Default: null (no empty row checking).
299     * Override in ranked report controllers to specify the identifying field(s) to check.
300     * Returns an array of field names (all must be empty to skip the row).
301     *
302     * @return array|null Array of field name(s) to check, or null to skip empty checking.
303     */
304    public function get_empty_row_check_field() {
305        return null;
306    }
307
308    // ============================================================================
309    // Helper Methods
310    // ============================================================================
311
312    /**
313     * Format a time interval for display in CSV.
314     *
315     * If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'.
316     *
317     * @param array       $item     The data item containing date_start.
318     * @param string|null $interval Optional time interval. If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'.
319     * @return string The formatted date string.
320     */
321    protected function format_time_interval( array $item, ?string $interval = null ): string {
322        if ( ! isset( $item['date_start'] ) || empty( $item['date_start'] ) ) {
323            return '';
324        }
325
326        try {
327            $datetime = new DateTime( $item['date_start'] );
328            $format   = ( 'hour' === $interval ) ? 'Y-m-d H:00' : 'Y-m-d';
329            return $datetime->format( $format );
330        } catch ( Exception $e ) {
331            return '';
332        }
333    }
334
335    /**
336     * Format a monetary amount for display in CSV.
337     *
338     * @param mixed $amount The amount to format.
339     * @return string The formatted amount.
340     */
341    protected static function format_amount( $amount ): string {
342        if ( is_numeric( $amount ) ) {
343            return number_format( (float) $amount, 2, '.', '' );
344        }
345        return '0.00';
346    }
347
348    /**
349     * Get the label for a time interval.
350     *
351     * @param string|null $interval The time interval (hour, day, week, month, quarter, year).
352     * @return string The translated interval label.
353     */
354    protected function get_interval_label( ?string $interval = null ): string {
355        $labels = array(
356            'hour'    => __( 'Hour', 'jetpack-premium-analytics' ),
357            'day'     => __( 'Day', 'jetpack-premium-analytics' ),
358            'week'    => __( 'Week', 'jetpack-premium-analytics' ),
359            'month'   => __( 'Month', 'jetpack-premium-analytics' ),
360            'quarter' => __( 'Quarter', 'jetpack-premium-analytics' ),
361            'year'    => __( 'Year', 'jetpack-premium-analytics' ),
362        );
363
364        return $labels[ $interval ?? '' ] ?? __( 'Date', 'jetpack-premium-analytics' );
365    }
366
367    /**
368     * Extract data from an item based on prefix matching.
369     *
370     * @param array  $item          The raw data item.
371     * @param string $prefix        The prefix to match against.
372     * @param bool   $match_prefix  If true, include keys with prefix; if false, exclude keys with prefix.
373     * @param bool   $strip_prefix  If true, strip the prefix from extracted keys.
374     * @return array Extracted data item and a flag indicating if all values are empty.
375     */
376    protected function extract_data_by_prefix( array $item, string $prefix, bool $match_prefix, bool $strip_prefix = false ): array {
377        $extracted_item = array();
378        $all_empty      = true;
379        $prefix_length  = strlen( $prefix );
380
381        foreach ( $item as $key => $value ) {
382            $has_prefix = ( strpos( $key, $prefix ) === 0 );
383
384            // Include key if it matches our criteria (has prefix when match_prefix is true, or doesn't have prefix when match_prefix is false).
385            if ( $has_prefix === $match_prefix ) {
386                $extracted_key                    = $strip_prefix && $has_prefix ? substr( $key, $prefix_length ) : $key;
387                $extracted_item[ $extracted_key ] = $value;
388
389                // Check if all values are empty strings.
390                if ( '' !== $value ) {
391                    $all_empty = false;
392                }
393            }
394        }
395
396        return array(
397            'item'      => $extracted_item,
398            'all_empty' => $all_empty,
399        );
400    }
401
402    /**
403     * Format a row with empty value handling.
404     *
405     * Handles empty row checking based on controller configuration:
406     * - Checks configured field(s) for emptiness
407     * - Skips row if configured and empty (unless should_include_empty_rows() is true)
408     * - Includes row with custom label if configured
409     *
410     * If all values in the extracted item are empty strings, returns a row
411     * with all fields set to empty strings instead of using default values.
412     *
413     * @param array       $extracted_data The extracted data item and empty flag.
414     * @param array       $item_to_format The item to use for formatting (may differ from extracted data).
415     * @param string|null $interval       Optional time interval for formatting.
416     * @return array The formatted row, or empty array if row should be skipped.
417     */
418    protected function format_row_with_empty_handling( array $extracted_data, array $item_to_format, ?string $interval = null ): array {
419        $item      = $extracted_data['item'];
420        $all_empty = $extracted_data['all_empty'];
421
422        // Check if this row should be skipped due to empty identifying fields.
423        $check_fields = $this->get_empty_row_check_field();
424        $is_empty     = ( null !== $check_fields ) ? $this->is_row_empty( $item, $check_fields ) : false;
425
426        if ( $is_empty ) {
427            // Row has empty identifying field(s).
428            if ( ! $this->should_include_empty_rows() ) {
429                // Skip this row entirely.
430                return array();
431            }
432            // Include the row but we'll replace empty identifying field with custom label later.
433        }
434
435        // If all values are empty strings, return empty strings for all fields
436        // instead of using default values from format_row_for_csv().
437        if ( $all_empty && ! empty( $item ) ) {
438            // Get the expected field structure by formatting an empty item.
439            // This gives us the field names, but we'll set all values to empty strings.
440            $row_structure = $this->format_row_for_csv( array(), $interval );
441            $row           = array();
442            foreach ( array_keys( $row_structure ) as $key ) {
443                $row[ $key ] = '';
444            }
445            return $row;
446        }
447
448        // Format the row normally.
449        $row = $this->format_row_for_csv( $item_to_format, $interval );
450
451        // If we determined the row is empty but should be included, replace empty identifying field with custom label.
452        // $is_empty implies $check_fields is non-null (see above), but check explicitly for the type checker.
453        if ( $is_empty && null !== $check_fields && $this->should_include_empty_rows() ) {
454            $row = $this->apply_empty_row_label( $row, $check_fields );
455        }
456
457        return $row;
458    }
459
460    /**
461     * Check if a row is considered empty based on the configured check field(s).
462     *
463     * @param array $item         The data item to check.
464     * @param array $check_fields Array of field names to check for emptiness.
465     * @return bool True if the row is empty, false otherwise.
466     */
467    protected function is_row_empty( array $item, array $check_fields ): bool {
468        // All specified fields must be empty for the row to be considered empty.
469        foreach ( $check_fields as $field ) {
470            // Use strict comparison to avoid treating '0' as empty.
471            if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) {
472                return false;
473            }
474        }
475
476        return true;
477    }
478
479    /**
480     * Apply the custom empty row label to the formatted row.
481     *
482     * Replaces the value of the identifying field(s) with the custom label.
483     * If the field name changed during formatting, child classes should override this method.
484     *
485     * Note: When multiple check fields are configured (e.g., ['campaign', 'label']),
486     * only the first matching empty field receives the label. This is intentional
487     * because the first field is typically the primary identifier (index/label column)
488     * that should be displayed to users. Applying the label to multiple columns would
489     * be redundant and could confuse users.
490     *
491     * @param array $row          The formatted row data.
492     * @param array $check_fields The field names that were empty.
493     * @return array The row with custom label applied.
494     */
495    protected function apply_empty_row_label( array $row, array $check_fields ): array {
496        $custom_label = $this->get_empty_row_label();
497
498        // Update the specified check field(s) if they exist in the formatted row.
499        foreach ( $check_fields as $field_name ) {
500            if ( isset( $row[ $field_name ] ) && '' === $row[ $field_name ] ) {
501                $row[ $field_name ] = $custom_label;
502                return $row; // Only update the first matching empty field.
503            }
504        }
505
506        return $row;
507    }
508
509    /**
510     * Automatically add comparison fields to a formatted row.
511     *
512     * This helper method dynamically adds comparison data by:
513     * 1. Checking if comparison data exists (any comparison_ prefixed field)
514     * 2. Creating a synthetic item with comparison_ prefixes stripped
515     * 3. Calling format_row_for_csv() on the synthetic item (applying all field mapping)
516     * 4. Adding comparison_ prefix back to the formatted keys
517     * 5. Merging into the original row
518     *
519     * This ensures that field name mapping (e.g., orders_value_net → net_sales)
520     * is applied consistently to both base and comparison data.
521     *
522     * @param array       $row      The formatted row with original data.
523     * @param array       $item     The raw item with both original and comparison data.
524     * @param string|null $interval Optional time interval for formatting.
525     * @return array The row with comparison fields added.
526     */
527    protected function add_comparison_fields( array $row, array $item, ?string $interval = null ): array {
528        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
529
530        // Check if comparison data exists by looking for any comparison_ prefixed field.
531        $has_comparison_data = false;
532        foreach ( array_keys( $item ) as $key ) {
533            if ( strpos( $key, $prefix ) === 0 ) {
534                $has_comparison_data = true;
535                break;
536            }
537        }
538
539        if ( ! $has_comparison_data ) {
540            return $row;
541        }
542
543        // Extract comparison data with prefix stripped.
544        // Note: We format the extracted comparison item (with prefix stripped), not the full item.
545        $comparison_data = $this->extract_data_by_prefix( $item, $prefix, true, true );
546        $comparison_row  = $this->format_row_with_empty_handling( $comparison_data, $comparison_data['item'], $interval );
547
548        // Add comparison_ prefix back to the formatted keys and merge into main row.
549        foreach ( $comparison_row as $key => $value ) {
550            $row[ $prefix . $key ] = $value;
551        }
552
553        return $row;
554    }
555}