Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.96% covered (warning)
86.96%
60 / 69
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrackingToolUpdater
86.96% covered (warning)
86.96%
60 / 69
20.00% covered (danger)
20.00%
1 / 5
19.80
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.78% covered (success)
97.78%
44 / 45
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            ->forUpdate()
84            ->caller( __METHOD__ )
85            ->fetchResultSet();
86
87        // TODO Add support for multiple tracking tools per event
88        if ( count( $currentToolRows ) > 1 && !defined( 'MW_PHPUNIT_TEST' ) ) {
89            throw new LogicException( "Events should have only one tracking tool." );
90        }
91
92        // Delete rows for tools that are no longer connected or with outdated sync info
93        $deleteIDs = [];
94        foreach ( $currentToolRows as $curRow ) {
95            $lookupKey = $curRow->cett_tool_id . '|' . $curRow->cett_tool_event_id;
96            $syncStatus = self::dbSyncStatusToConst( (int)$curRow->cett_sync_status );
97            if (
98                !isset( $toolsMap[$lookupKey] ) ||
99                $syncStatus !== $toolsMap[$lookupKey]->getSyncStatus() ||
100                wfTimestampOrNull( TS_UNIX, $curRow->cett_last_sync ) !== $toolsMap[$lookupKey]->getLastSyncTimestamp()
101            ) {
102                $deleteIDs[] = $curRow->cett_id;
103            }
104        }
105
106        if ( $deleteIDs ) {
107            $dbw->newDeleteQueryBuilder()
108                ->deleteFrom( 'ce_tracking_tools' )
109                ->where( [ 'cett_id' => $deleteIDs ] )
110                ->caller( __METHOD__ )
111                ->execute();
112        }
113
114        $newRows = [];
115        foreach ( $tools as $toolAssoc ) {
116            $syncTS = $toolAssoc->getLastSyncTimestamp();
117            $newRows[] = [
118                'cett_event' => $eventID,
119                'cett_tool_id' => $toolAssoc->getToolID(),
120                'cett_tool_event_id' => $toolAssoc->getToolEventID(),
121                'cett_sync_status' => self::syncStatusToDB( $toolAssoc->getSyncStatus() ),
122                'cett_last_sync' => $syncTS !== null ? $dbw->timestamp( $syncTS ) : $syncTS,
123            ];
124        }
125
126        if ( $newRows ) {
127            // Insert the remaining rows. We can ignore conflicting rows in the database, as the checks above guarantee
128            // that they're identical to the new rows.
129            $dbw->newInsertQueryBuilder()
130                ->insertInto( 'ce_tracking_tools' )
131                ->ignore()
132                ->rows( $newRows )
133                ->caller( __METHOD__ )
134                ->execute();
135        }
136    }
137
138    /**
139     * Updates the sync status of a tool in the database, updating the last sync timestamp if $status is
140     * SYNC_STATUS_SYNCED.
141     *
142     * @param int $eventID
143     * @param int $toolID
144     * @param string $toolEventID
145     * @param int $status One of the TrackingToolAssociation::SYNC_STATUS_* constants
146     */
147    public function updateToolSyncStatus( int $eventID, int $toolID, string $toolEventID, int $status ): void {
148        $dbw = $this->dbHelper->getDBConnection( DB_PRIMARY );
149        $setConds = [
150            'cett_sync_status' => self::syncStatusToDB( $status ),
151        ];
152        if ( $status === TrackingToolAssociation::SYNC_STATUS_SYNCED ) {
153            $setConds['cett_last_sync'] = $dbw->timestamp();
154        }
155
156        $dbw->newUpdateQueryBuilder()
157            ->update( 'ce_tracking_tools' )
158            ->set( $setConds )
159            ->where( [
160                'cett_event' => $eventID,
161                'cett_tool_id' => $toolID,
162                'cett_tool_event_id' => $toolEventID
163            ] )
164            ->caller( __METHOD__ )
165            ->execute();
166    }
167}