Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.86% covered (warning)
67.86%
38 / 56
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Id_Based_Merge_Strategy
69.09% covered (warning)
69.09%
38 / 55
33.33% covered (danger)
33.33%
1 / 3
34.02
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 merge
60.00% covered (warning)
60.00%
24 / 40
0.00% covered (danger)
0.00%
0 / 1
23.82
 copy_matching_and_identifying_fields
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
1<?php
2/**
3 * ID-Based Merge Strategy
4 *
5 * Merges comparison data by matching field value for ranked reports.
6 *
7 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export\MergeStrategy
8 */
9
10declare( strict_types=1 );
11
12namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export\MergeStrategy;
13
14defined( 'ABSPATH' ) || exit;
15
16use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Csv_Report_Controller_Interface;
17use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface;
18use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait;
19
20/**
21 * ID-based merge strategy for ranked reports.
22 *
23 * This strategy merges data by matching a specific field (e.g., product_id, coupon_code).
24 * It's appropriate for ranked reports like Top Products where items may appear in
25 * different positions between periods.
26 *
27 * Empty value handling: Uses controller defaults (e.g., 0 for numeric fields) for missing
28 * comparison data, which signals "no sales/activity" rather than "no data".
29 *
30 * @since $$next-version$$
31 */
32class Id_Based_Merge_Strategy extends Abstract_Merge_Strategy {
33
34    use Logger_Trait;
35
36    /**
37     * The matching field name (e.g., 'product_id').
38     *
39     * @var string
40     */
41    private $matching_field;
42
43    /**
44     * Constructor.
45     *
46     * @param string           $matching_field The field to match on.
47     * @param Logger_Interface $logger         Logger instance.
48     */
49    public function __construct( string $matching_field, Logger_Interface $logger ) {
50        $this->matching_field = $matching_field;
51        $this->logger         = $logger;
52    }
53
54    /**
55     * Merge original and comparison data by matching field value.
56     *
57     * @param array                           $original_items   Items from original period.
58     * @param array                           $comparison_items Items from comparison period.
59     * @param string                          $prefix           Prefix for comparison field names.
60     * @param Csv_Report_Controller_Interface $controller       Controller for defaults and identifying fields.
61     * @return array Merged items with comparison data.
62     */
63    public function merge( array $original_items, array $comparison_items, string $prefix, Csv_Report_Controller_Interface $controller ): array {
64        // Build lookup: matching_field value => comparison item.
65        // Normalize keys to strings to avoid type mismatch issues (e.g., integer 19 vs string "19").
66        // Empty strings are normalized to "" to allow matching empty rows.
67        $comparison_map = array();
68        foreach ( $comparison_items as $item ) {
69            if ( isset( $item[ $this->matching_field ] ) ) {
70                // Normalize to string, preserving empty strings as "" (not converting to null).
71                $field_value    = $item[ $this->matching_field ];
72                $normalized_key = ( null !== $field_value ) ? (string) $field_value : '';
73
74                // Warn if there are duplicate matching field values.
75                if ( isset( $comparison_map[ $normalized_key ] ) ) {
76                    $this->logger->log_error(
77                        sprintf(
78                            'Duplicate matching field value "%s" found in comparison data. Only the last occurrence will be used.',
79                            $normalized_key
80                        ),
81                        __METHOD__
82                    );
83                }
84
85                $comparison_map[ $normalized_key ] = $item;
86            }
87        }
88
89        // Get template for empty comparison data.
90        $comparison_template = ! empty( $comparison_items )
91            ? $comparison_items[0]
92            : ( ! empty( $original_items ) ? $original_items[0] : array() );
93
94        $merged_items = array();
95
96        // Match original items with comparison data.
97        foreach ( $original_items as $original_item ) {
98            $merged_item = $original_item;
99            $match_value = $original_item[ $this->matching_field ] ?? null;
100
101            // Warn if the matching field is missing from this item.
102            if ( ! isset( $original_item[ $this->matching_field ] ) ) {
103                $this->logger->log_error(
104                    sprintf(
105                        'Item missing matching field "%s". Item will have empty comparison data. Available fields: %s',
106                        $this->matching_field,
107                        implode( ', ', array_keys( $original_item ) )
108                    ),
109                    __METHOD__
110                );
111            }
112
113            // Normalize match value to string for consistent lookup.
114            // Preserve empty strings as "" (not converting to null) to allow matching empty rows.
115            // This allows falsy but valid values like 0 or "0" to be matched, and also allows
116            // empty strings to be matched (important for empty row handling).
117            $normalized_match_value = ( null !== $match_value ) ? (string) $match_value : '';
118
119            if ( isset( $comparison_map[ $normalized_match_value ] ) ) {
120                // Found matching comparison data.
121                foreach ( $comparison_map[ $normalized_match_value ] as $key => $value ) {
122                    $merged_item[ $prefix . $key ] = $value;
123                }
124            } else {
125                // No match - use default values (NOT empty strings).
126                $empty_comparison = $this->create_empty_item( $comparison_template, $controller );
127                foreach ( $empty_comparison as $key => $value ) {
128                    $merged_item[ $prefix . $key ] = $value;
129                }
130            }
131
132            // Copy matching field and identifying/display fields from original item when comparison fields are empty.
133            // This handles both cases: when comparison data is missing entirely, and when
134            // comparison data exists but identifying fields are empty.
135            // The entity is the same (matched by ID), so these fields should be preserved.
136            $this->copy_matching_and_identifying_fields( $merged_item, $original_item, $prefix, $controller );
137
138            $merged_items[] = $merged_item;
139        }
140
141        return $merged_items;
142    }
143
144    /**
145     * Copy matching field and identifying fields from original item to comparison item.
146     *
147     * When matching by ID, the entity is the same across periods, so the matching field
148     * (e.g., 'channel', 'device') and identifying fields (e.g., 'label') should be preserved
149     * even when comparison data is missing.
150     *
151     * @param array                           $merged_item   The merged item (modified in place).
152     * @param array                           $original_item The original item to copy from.
153     * @param string                          $prefix        The prefix for comparison fields.
154     * @param Csv_Report_Controller_Interface $controller    Controller specifying which fields to preserve.
155     * @return void
156     */
157    private function copy_matching_and_identifying_fields( array &$merged_item, array $original_item, string $prefix, Csv_Report_Controller_Interface $controller ): void {
158        // Build list of fields to copy: matching field + identifying fields.
159        $fields_to_copy     = array( $this->matching_field );
160        $identifying_fields = $controller->get_identifying_fields();
161        if ( ! empty( $identifying_fields ) ) {
162            $fields_to_copy = array_merge( $fields_to_copy, $identifying_fields );
163        }
164
165        // Copy each field from original to comparison if needed.
166        foreach ( $fields_to_copy as $field ) {
167            // Skip if the field doesn't exist in the original item.
168            if ( ! isset( $original_item[ $field ] ) ) {
169                continue;
170            }
171
172            $value = $original_item[ $field ];
173
174            // Skip if the value is empty string.
175            // Note: We check for empty string explicitly to allow "0" strings and other falsy values.
176            if ( '' === $value ) {
177                continue;
178            }
179
180            $comparison_key = $prefix . $field;
181            // Only copy if the comparison field is missing or is an empty string.
182            // Note: We check for empty string explicitly to allow "0" strings.
183            if ( ! isset( $merged_item[ $comparison_key ] ) || '' === $merged_item[ $comparison_key ] ) {
184                $merged_item[ $comparison_key ] = $value;
185            }
186        }
187    }
188}