Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.97% covered (warning)
70.97%
66 / 93
52.38% covered (warning)
52.38%
11 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Csv_Report_Controller
71.74% covered (warning)
71.74%
66 / 92
52.38% covered (warning)
52.38%
11 / 21
109.71
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%
8 / 8
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 the per-report CSV export controllers registered in Report_Registry.
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 DateTime;
17use Exception;
18
19/**
20 * Abstract base class for CSV report controllers.
21 *
22 * @since 0.1.0
23 */
24abstract class Abstract_Csv_Report_Controller implements Csv_Report_Controller_Interface {
25
26    /**
27     * Default batch limit for time-based reports.
28     */
29    protected const DEFAULT_BATCH_LIMIT = 1000;
30
31    /**
32     * Default date type for order-based reports.
33     */
34    protected const DEFAULT_DATE_TYPE = 'created';
35
36    /**
37     * Report registry instance.
38     *
39     * @var Report_Registry
40     */
41    protected $registry;
42
43    /**
44     * Cached result of should_include_empty_rows() to avoid repeated filter calls.
45     *
46     * @var bool|null
47     */
48    private $cached_include_empty_rows = null;
49
50    /**
51     * Constructor.
52     *
53     * @param Report_Registry $registry Registry instance (injected by DI).
54     */
55    public function __construct( Report_Registry $registry ) {
56        $this->registry = $registry;
57    }
58
59    /**
60     * Register this controller with the report registry.
61     *
62     * @return void
63     */
64    public function register(): void {
65        $this->registry->register_controller( $this );
66    }
67
68    /**
69     * Format a row with automatic comparison field handling.
70     *
71     * @param array       $item     The raw data item.
72     * @param string|null $interval Optional time interval for formatting.
73     * @return array The formatted row with comparison fields.
74     */
75    public function format_row_with_comparison( array $item, ?string $interval = null ): array {
76        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
77
78        // Extract original data (fields without comparison_ prefix) to check for empty values.
79        // Note: We format the full $item, not the extracted subset, to preserve all data.
80        $original_data = $this->extract_data_by_prefix( $item, $prefix, false );
81        $row           = $this->format_row_with_empty_handling( $original_data, $item, $interval );
82
83        return $this->add_comparison_fields( $row, $item, $interval );
84    }
85
86    /**
87     * Get the report key (unique identifier).
88     *
89     * @return string
90     */
91    abstract public function get_report_key(): string;
92
93    /**
94     * Get the report label (human-readable name).
95     *
96     * @return string
97     */
98    abstract public function get_report_label(): string;
99
100    /**
101     * Get the data endpoint (API route).
102     *
103     * @return string
104     */
105    abstract public function get_data_endpoint(): string;
106
107    /**
108     * Get the column headers for CSV export.
109     *
110     * @param string|null $interval Optional time interval for dynamic headers.
111     * @return array
112     */
113    abstract public function get_column_headers( ?string $interval = null ): array;
114
115    /**
116     * Format a single data item for CSV export.
117     *
118     * Return the base row only; format_row_with_comparison() adds the comparison fields.
119     *
120     * @param array       $item     The raw data item.
121     * @param string|null $interval Optional time interval for formatting.
122     * @return array The formatted row for CSV.
123     */
124    abstract public function format_row_for_csv( array $item, ?string $interval = null ): array;
125
126    /**
127     * Get default values for missing data fields.
128     *
129     * Used to build empty items when comparison data is missing.
130     *
131     * @return array Array of field_name => default_value pairs.
132     */
133    abstract public function get_default_values(): array;
134
135    /**
136     * Get the batch limit (max items per request).
137     *
138     * @return int
139     */
140    public function get_batch_limit(): int {
141        return self::DEFAULT_BATCH_LIMIT;
142    }
143
144    /**
145     * Get additional request parameters for data fetching.
146     *
147     * @return array Additional parameters to include in data requests.
148     */
149    public function get_additional_params(): array {
150        return array();
151    }
152
153    /**
154     * Get the list of fields to request from the API.
155     *
156     * Override to request only the fields this report needs, shrinking the API response.
157     *
158     * @return array Field names to request, or empty array for all fields.
159     */
160    public function get_fields(): array {
161        return array();
162    }
163
164    /**
165     * Get the matching field for comparison data alignment.
166     *
167     * Default: null (index-based matching for time-series reports).
168     * Override in child classes for ID-based matching (ranked reports).
169     *
170     * @return string|null
171     */
172    public function get_matching_field(): ?string {
173        return null;
174    }
175
176    /**
177     * Get the identifying fields that should be preserved in comparison data.
178     *
179     * Default: empty array (no fields preserved for time-series reports). Ranked reports name
180     * identifying fields here so they are copied over when comparison data is missing.
181     *
182     * @return array Array of field names to preserve.
183     */
184    public function get_identifying_fields(): array {
185        return array();
186    }
187
188    /**
189     * Whether to use array format for filter values in IN filters.
190     *
191     * Default: false (comma-separated format for URL efficiency).
192     * Override in controllers that require array format (e.g., order-attribution).
193     *
194     * @return bool True to use array format, false for comma-separated (default).
195     */
196    public function use_array_filter_format(): bool {
197        return false;
198    }
199
200    /**
201     * Whether to include empty rows by default.
202     *
203     * @return bool True to include empty rows, false to exclude them.
204     */
205    protected function should_include_empty_rows_by_default(): bool {
206        return true;
207    }
208
209    /**
210     * Whether to include rows with empty identifying fields in the export.
211     *
212     * The controller's default, overridable via 'jetpack_premium_analytics_csv_include_empty_rows';
213     * cached per instance since it's consulted once per row.
214     *
215     * @return bool True to include empty rows with custom label, false to skip them.
216     */
217    public function should_include_empty_rows(): bool {
218        if ( null !== $this->cached_include_empty_rows ) {
219            return $this->cached_include_empty_rows;
220        }
221
222        $default = $this->should_include_empty_rows_by_default();
223
224        /**
225         * Filter whether to include empty rows in CSV exports.
226         *
227         * This filter takes precedence over controller defaults, allowing global control.
228         *
229         * @param bool   $include_empty Whether to include empty rows (controller default).
230         * @param string $report_key    The report key for this controller.
231         * @param object $controller    The controller instance.
232         */
233        $this->cached_include_empty_rows = apply_filters( 'jetpack_premium_analytics_csv_include_empty_rows', $default, $this->get_report_key(), $this );
234
235        return $this->cached_include_empty_rows;
236    }
237
238    /**
239     * Get the label to use for rows with empty identifying fields.
240     *
241     * Only used when should_include_empty_rows() returns true.
242     *
243     * @return string The label to use for empty rows.
244     */
245    public function get_empty_row_label(): string {
246        return __( 'Unassigned', 'jetpack-premium-analytics-pkg' );
247    }
248
249    /**
250     * Get the field name(s) to check for emptiness when determining if a row should be skipped.
251     *
252     * Default: null (no empty row checking). When an array is returned, every named field
253     * must be empty before the row counts as empty.
254     *
255     * @return array|null Array of field name(s) to check, or null to skip empty checking.
256     */
257    public function get_empty_row_check_field() {
258        return null;
259    }
260
261    /**
262     * Format a time interval for display in CSV.
263     *
264     * @param array       $item     The data item containing date_start.
265     * @param string|null $interval Optional time interval. If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'.
266     * @return string The formatted date string.
267     */
268    protected function format_time_interval( array $item, ?string $interval = null ): string {
269        if ( ! isset( $item['date_start'] ) || empty( $item['date_start'] ) ) {
270            return '';
271        }
272
273        try {
274            $datetime = new DateTime( $item['date_start'] );
275            $format   = ( 'hour' === $interval ) ? 'Y-m-d H:00' : 'Y-m-d';
276            return $datetime->format( $format );
277        } catch ( Exception $e ) {
278            return '';
279        }
280    }
281
282    /**
283     * Format a monetary amount for display in CSV.
284     *
285     * @param mixed $amount The amount to format.
286     * @return string The formatted amount.
287     */
288    protected static function format_amount( $amount ): string {
289        if ( is_numeric( $amount ) ) {
290            return number_format( (float) $amount, 2, '.', '' );
291        }
292        return '0.00';
293    }
294
295    /**
296     * Get the label for a time interval.
297     *
298     * @param string|null $interval The time interval (hour, day, week, month, year).
299     * @return string The translated interval label.
300     */
301    protected function get_interval_label( ?string $interval = null ): string {
302        $labels = array(
303            'hour'  => __( 'Hour', 'jetpack-premium-analytics-pkg' ),
304            'day'   => __( 'Day', 'jetpack-premium-analytics-pkg' ),
305            'week'  => __( 'Week', 'jetpack-premium-analytics-pkg' ),
306            'month' => __( 'Month', 'jetpack-premium-analytics-pkg' ),
307            'year'  => __( 'Year', 'jetpack-premium-analytics-pkg' ),
308        );
309
310        return $labels[ $interval ?? '' ] ?? __( 'Date', 'jetpack-premium-analytics-pkg' );
311    }
312
313    /**
314     * Extract data from an item based on prefix matching.
315     *
316     * @param array  $item          The raw data item.
317     * @param string $prefix        The prefix to match against.
318     * @param bool   $match_prefix  If true, include keys with prefix; if false, exclude keys with prefix.
319     * @param bool   $strip_prefix  If true, strip the prefix from extracted keys.
320     * @return array Extracted data item and a flag indicating if all values are empty.
321     */
322    protected function extract_data_by_prefix( array $item, string $prefix, bool $match_prefix, bool $strip_prefix = false ): array {
323        $extracted_item = array();
324        $all_empty      = true;
325        $prefix_length  = strlen( $prefix );
326
327        foreach ( $item as $key => $value ) {
328            $has_prefix = ( strpos( $key, $prefix ) === 0 );
329
330            if ( $has_prefix === $match_prefix ) {
331                $extracted_key                    = $strip_prefix && $has_prefix ? substr( $key, $prefix_length ) : $key;
332                $extracted_item[ $extracted_key ] = $value;
333
334                if ( '' !== $value ) {
335                    $all_empty = false;
336                }
337            }
338        }
339
340        return array(
341            'item'      => $extracted_item,
342            'all_empty' => $all_empty,
343        );
344    }
345
346    /**
347     * Format a row with empty value handling.
348     *
349     * A row whose values are all empty strings keeps them, rather than picking up the
350     * controller's defaults — "no data" and "zero" must not collapse into each other.
351     *
352     * @param array       $extracted_data The extracted data item and empty flag.
353     * @param array       $item_to_format The item to use for formatting (may differ from extracted data).
354     * @param string|null $interval       Optional time interval for formatting.
355     * @return array The formatted row, or empty array if row should be skipped.
356     */
357    protected function format_row_with_empty_handling( array $extracted_data, array $item_to_format, ?string $interval = null ): array {
358        $item      = $extracted_data['item'];
359        $all_empty = $extracted_data['all_empty'];
360
361        $check_fields = $this->get_empty_row_check_field();
362        $is_empty     = ( null !== $check_fields ) ? $this->is_row_empty( $item, $check_fields ) : false;
363
364        if ( $is_empty ) {
365            if ( ! $this->should_include_empty_rows() ) {
366                // An empty array is the caller's "skip this row" signal.
367                return array();
368            }
369            // Otherwise keep the row; the empty identifying field gets the custom label below.
370        }
371
372        if ( $all_empty && ! empty( $item ) ) {
373            $row_structure = $this->format_row_for_csv( array(), $interval );
374            $row           = array();
375            foreach ( array_keys( $row_structure ) as $key ) {
376                $row[ $key ] = '';
377            }
378            return $row;
379        }
380
381        $row = $this->format_row_for_csv( $item_to_format, $interval );
382
383        // $is_empty implies $check_fields is non-null (see above), but check explicitly for the type checker.
384        if ( $is_empty && null !== $check_fields && $this->should_include_empty_rows() ) {
385            $row = $this->apply_empty_row_label( $row, $check_fields );
386        }
387
388        return $row;
389    }
390
391    /**
392     * Check if a row is considered empty based on the configured check field(s).
393     *
394     * @param array $item         The data item to check.
395     * @param array $check_fields Array of field names to check for emptiness.
396     * @return bool True if the row is empty, false otherwise.
397     */
398    protected function is_row_empty( array $item, array $check_fields ): bool {
399        // All specified fields must be empty for the row to be considered empty.
400        foreach ( $check_fields as $field ) {
401            // Use strict comparison to avoid treating '0' as empty.
402            if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) {
403                return false;
404            }
405        }
406
407        return true;
408    }
409
410    /**
411     * Apply the custom empty row label to the formatted row.
412     *
413     * Only the first empty check field (the primary identifier column) gets the label; child
414     * classes that rename the field while formatting must override this.
415     *
416     * @param array $row          The formatted row data.
417     * @param array $check_fields The field names that were empty.
418     * @return array The row with custom label applied.
419     */
420    protected function apply_empty_row_label( array $row, array $check_fields ): array {
421        $custom_label = $this->get_empty_row_label();
422
423        foreach ( $check_fields as $field_name ) {
424            if ( isset( $row[ $field_name ] ) && '' === $row[ $field_name ] ) {
425                $row[ $field_name ] = $custom_label;
426                return $row;
427            }
428        }
429
430        return $row;
431    }
432
433    /**
434     * Automatically add comparison fields to a formatted row.
435     *
436     * Comparison data round-trips through format_row_for_csv() with the prefix stripped and
437     * re-added, so field name mapping (orders_value_net → net_sales) applies to it too.
438     *
439     * @param array       $row      The formatted row with original data.
440     * @param array       $item     The raw item with both original and comparison data.
441     * @param string|null $interval Optional time interval for formatting.
442     * @return array The row with comparison fields added.
443     */
444    protected function add_comparison_fields( array $row, array $item, ?string $interval = null ): array {
445        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
446
447        $has_comparison_data = false;
448        foreach ( array_keys( $item ) as $key ) {
449            if ( strpos( $key, $prefix ) === 0 ) {
450                $has_comparison_data = true;
451                break;
452            }
453        }
454
455        if ( ! $has_comparison_data ) {
456            return $row;
457        }
458
459        $comparison_data = $this->extract_data_by_prefix( $item, $prefix, true, true );
460        $comparison_row  = $this->format_row_with_empty_handling( $comparison_data, $comparison_data['item'], $interval );
461
462        foreach ( $comparison_row as $key => $value ) {
463            $row[ $prefix . $key ] = $value;
464        }
465
466        return $row;
467    }
468}