Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.76% covered (warning)
86.76%
59 / 68
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrackingToolUpdater
86.76% covered (warning)
86.76%
59 / 68
20.00% covered (danger)
20.00%
1 / 5
19.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 syncStatusToDB
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 dbSyncStatusToConst
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 replaceEventTools
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
12
 updateToolSyncStatus
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\TrackingTool;
6
7use InvalidArgumentException;
8use LogicException;
9use MediaWiki\Extension\CampaignEvents\Database\CampaignsDatabaseHelper;
10use Wikimedia\Rdbms\IDatabase;
11
12/**
13 * This class updates the information about tracking tools stored in our database.
14 */
15class TrackingToolUpdater {
16    public const SERVICE_NAME = 'CampaignEventsTrackingToolUpdater';
17
18    private const SYNC_STATUS_TO_DB_MAP = [
19        TrackingToolAssociation::SYNC_STATUS_UNKNOWN => 1,
20        TrackingToolAssociation::SYNC_STATUS_SYNCED => 2,
21        TrackingToolAssociation::SYNC_STATUS_FAILED => 3,
22    ];
23
24    private CampaignsDatabaseHelper $dbHelper;
25
26    /**
27     * @param CampaignsDatabaseHelper $dbHelper
28     */
29    public function __construct( CampaignsDatabaseHelper $dbHelper ) {
30        $this->dbHelper = $dbHelper;
31    }
32
33    /**
34     * Converts a TrackingToolAssociation::SYNC_STATUS_* constant to the respective DB value
35     * @param int $status
36     * @return int
37     */
38    public static function syncStatusToDB( int $status ): int {
39        if ( !isset( self::SYNC_STATUS_TO_DB_MAP[$status] ) ) {
40            throw new InvalidArgumentException( "Unknown sync status $status" );
41        }
42        return self::SYNC_STATUS_TO_DB_MAP[$status];
43    }
44
45    /**
46     * Converts a DB value for ce_tracking_tools.cett_sync_status to the respective
47     * TrackingToolAssociation::SYNC_STATUS_* constant.
48     * @param int $dbVal
49     * @return int
50     */
51    public static function dbSyncStatusToConst( int $dbVal ): int {
52        $const = array_search( $dbVal, self::SYNC_STATUS_TO_DB_MAP, true );
53        if ( $const === false ) {
54            throw new InvalidArgumentException( "Unknown DB value for sync status: $dbVal" );
55        }
56        return $const;
57    }
58
59    /**
60     * Replaces the tools associated to an event with the given array of tool associations.
61     *
62     * @param int $eventID
63     * @param TrackingToolAssociation[] $tools
64     * @param IDatabase|null $dbw Optional, in case the caller opened an atomic section and wants to make sure
65     * that writes are done on the same DB handle.
66     * @return void
67     */
68    public function replaceEventTools( int $eventID, array $tools, ?IDatabase $dbw = null ): void {
69        $dbw ??= $this->dbHelper->getDBConnection( DB_PRIMARY );
70
71        // Make a map of tools with faster lookup to compare existing values
72        $toolsMap = [];
73        foreach ( $tools as $toolAssociation ) {
74            $key = $toolAssociation->getToolID() . '|' . $toolAssociation->getToolEventID();
75            $toolsMap[$key] = $toolAssociation;
76        }
77
78        // Make changes by primary key to avoid lock contention
79        $currentToolRows = $dbw->newSelectQueryBuilder()
80            ->select( '*' )
81            ->from( 'ce_tracking_tools' )
82            ->where( [ 'cett_event' => $eventID ] )
83            ->caller( __METHOD__ )
84            ->fetchResultSet();
85
86        // TODO Add support for multiple tracking tools per event
87        if ( count( $currentToolRows ) > 1 && !defined( 'MW_PHPUNIT_TEST' ) ) {
88            throw new LogicException( "Events should have only one tracking tool." );
89        }
90
91        // Delete rows for tools that are no longer connected or with outdated sync info
92        $deleteIDs = [];
93        foreach ( $currentToolRows as $curRow ) {
94            $lookupKey = $curRow->cett_tool_id . '|' . $curRow->cett_tool_event_id;
95            $syncStatus = self::dbSyncStatusToConst( (int)$curRow->cett_sync_status );
96            if (
97                !isset( $toolsMap[$lookupKey] ) ||
98                $syncStatus !== $toolsMap[$lookupKey]->getSyncStatus() ||
99                wfTimestampOrNull( TS_UNIX, $curRow->cett_last_sync ) !== $toolsMap[$lookupKey]->getLastSyncTimestamp()
100            ) {
101                $deleteIDs[] = $curRow->cett_id;
102            }
103        }
104
105        if ( $deleteIDs ) {
106            $dbw->newDeleteQueryBuilder()
107                ->deleteFrom( 'ce_tracking_tools' )
108                ->where( [ 'cett_id' => $deleteIDs ] )
109                ->caller( __METHOD__ )
110                ->execute();
111        }
112
113        $newRows = [];
114        foreach ( $tools as $toolAssoc ) {
115            $syncTS = $toolAssoc->getLastSyncTimestamp();
116            $newRows[] = [
117                'cett_event' => $eventID,
118                'cett_tool_id' => $toolAssoc->getToolID(),
119                'cett_tool_event_id' => $toolAssoc->getToolEventID(),
120                'cett_sync_status' => self::syncStatusToDB( $toolAssoc->getSyncStatus() ),
121                'cett_last_sync' => $syncTS !== null ? $dbw->timestamp( $syncTS ) : $syncTS,
122            ];
123        }
124
125        if ( $newRows ) {
126            // Insert the remaining rows. We can ignore conflicting rows in the database, as the checks above guarantee
127            // that they're identical to the new rows.
128            $dbw->newInsertQueryBuilder()
129                ->insertInto( 'ce_tracking_tools' )
130                ->ignore()
131                ->rows( $newRows )
132                ->caller( __METHOD__ )
133                ->execute();
134        }
135    }
136
137    /**
138     * Updates the sync status of a tool in the database, updating the last sync timestamp if $status is
139     * SYNC_STATUS_SYNCED.
140     *
141     * @param int $eventID
142     * @param int $toolID
143     * @param string $toolEventID
144     * @param int $status One of the TrackingToolAssociation::SYNC_STATUS_* constants
145     */
146    public function updateToolSyncStatus( int $eventID, int $toolID, string $toolEventID, int $status ): void {
147        $dbw = $this->dbHelper->getDBConnection( DB_PRIMARY );
148        $setConds = [
149            'cett_sync_status' => self::syncStatusToDB( $status ),
150        ];
151        if ( $status === TrackingToolAssociation::SYNC_STATUS_SYNCED ) {
152            $setConds['cett_last_sync'] = $dbw->timestamp();
153        }
154
155        $dbw->newUpdateQueryBuilder()
156            ->update( 'ce_tracking_tools' )
157            ->set( $setConds )
158            ->where( [
159                'cett_event' => $eventID,
160                'cett_tool_id' => $toolID,
161                'cett_tool_event_id' => $toolEventID
162            ] )
163            ->caller( __METHOD__ )
164            ->execute();
165    }
166}