Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
70.97% |
66 / 93 |
|
52.38% |
11 / 21 |
CRAP | |
0.00% |
0 / 1 |
| Abstract_Csv_Report_Controller | |
71.74% |
66 / 92 |
|
52.38% |
11 / 21 |
109.71 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| format_row_with_comparison | |
100.00% |
4 / 4 |
|
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% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_additional_params | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_fields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_matching_field | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_identifying_fields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| use_array_filter_format | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| should_include_empty_rows_by_default | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| should_include_empty_rows | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| get_empty_row_label | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_empty_row_check_field | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| format_time_interval | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| format_amount | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_interval_label | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| extract_data_by_prefix | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
| format_row_with_empty_handling | |
52.94% |
9 / 17 |
|
0.00% |
0 / 1 |
20.42 | |||
| is_row_empty | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| apply_empty_row_label | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| add_comparison_fields | |
100.00% |
13 / 13 |
|
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 | |
| 10 | declare( strict_types=1 ); |
| 11 | |
| 12 | namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export; |
| 13 | |
| 14 | defined( 'ABSPATH' ) || exit; |
| 15 | |
| 16 | use DateTime; |
| 17 | use Exception; |
| 18 | |
| 19 | /** |
| 20 | * Abstract base class for CSV report controllers. |
| 21 | * |
| 22 | * @since 0.1.0 |
| 23 | */ |
| 24 | abstract 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 | } |