Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.39% covered (warning)
80.39%
82 / 102
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinkTargetStore
80.39% covered (warning)
80.39%
82 / 102
33.33% covered (danger)
33.33%
3 / 9
29.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newLinkTargetFromRow
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
2.21
 getLinkTargetById
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 getLinkTargetId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 acquireLinkTargetId
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
6.32
 fetchIdFromDbPrimary
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 addToClassCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clearClassCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLinkTargetIdFromCache
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
5.01
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Linker;
22
23use BagOStuff;
24use InvalidArgumentException;
25use MediaWiki\Title\TitleValue;
26use RuntimeException;
27use stdClass;
28use WANObjectCache;
29use Wikimedia\Rdbms\IConnectionProvider;
30use Wikimedia\Rdbms\IDatabase;
31
32/**
33 * Service for retrieving and storing link targets.
34 *
35 * @since 1.38
36 */
37class LinkTargetStore implements LinkTargetLookup {
38
39    /** @var IConnectionProvider */
40    private $dbProvider;
41
42    /** @var BagOStuff */
43    private $localCache;
44
45    /** @var WANObjectCache */
46    private $wanObjectCache;
47
48    /** @var array<int, LinkTarget> */
49    private $byIdCache;
50
51    /** @var array<string, int> */
52    private $byTitleCache;
53
54    /**
55     * @param IConnectionProvider $dbProvider
56     * @param BagOStuff $localCache
57     * @param WANObjectCache $WanObjectCache
58     */
59    public function __construct(
60        IConnectionProvider $dbProvider,
61        BagOStuff $localCache,
62        WANObjectCache $WanObjectCache
63    ) {
64        $this->dbProvider = $dbProvider;
65        $this->localCache = $localCache;
66        $this->wanObjectCache = $WanObjectCache;
67        $this->byIdCache = [];
68        $this->byTitleCache = [];
69    }
70
71    /**
72     * @inheritDoc
73     */
74    public function newLinkTargetFromRow( stdClass $row ): LinkTarget {
75        $ltId = (int)$row->lt_id;
76        if ( $ltId === 0 ) {
77            throw new InvalidArgumentException(
78                "LinkTarget ID is 0 for {$row->lt_title} (ns: {$row->lt_namespace})"
79            );
80        }
81
82        $titlevalue = new TitleValue( (int)$row->lt_namespace, $row->lt_title );
83        $this->addToClassCache( $ltId, $titlevalue );
84        return $titlevalue;
85    }
86
87    /**
88     * Find a link target by $id.
89     *
90     * @param int $linkTargetId
91     * @return LinkTarget|null Returns null if no link target with this $linkTargetId exists in the database.
92     */
93    public function getLinkTargetById( int $linkTargetId ): ?LinkTarget {
94        if ( !$linkTargetId ) {
95            return null;
96        }
97
98        if ( isset( $this->byIdCache[$linkTargetId] ) ) {
99            return $this->byIdCache[$linkTargetId];
100        }
101
102        $value = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
103            ->caller( __METHOD__ )
104            ->table( 'linktarget' )
105            ->conds( [ 'lt_id' => $linkTargetId ] )
106            ->fields( [ 'lt_id', 'lt_namespace', 'lt_title' ] )
107            ->fetchRow();
108        if ( !$value ) {
109            return null;
110        }
111
112        // TODO: Use local and wan cache when writing read new code.
113        $linkTarget = $this->newLinkTargetFromRow( $value );
114        $this->addToClassCache( $linkTargetId, $linkTarget );
115        return $linkTarget;
116    }
117
118    /**
119     * Return link target id if exists
120     *
121     * @param LinkTarget $linkTarget
122     * @return int|null linktarget ID greater then 0, null if not found
123     */
124    public function getLinkTargetId( LinkTarget $linkTarget ): ?int {
125        // allow cache to be used, because if it is in the cache, it already has a linktarget id
126        return $this->getLinkTargetIdFromCache( $linkTarget ) ?: null;
127    }
128
129    /**
130     * Attempt to assign a link target ID to the given $linkTarget. If it is already assigned,
131     * return the existing ID.
132     *
133     * @note If called within a transaction, the returned ID might become invalid
134     * if the transaction is rolled back, so it should not be passed outside of the
135     * transaction context.
136     *
137     * @param LinkTarget $linkTarget
138     * @param IDatabase $dbw The database connection to acquire the ID from.
139     * @return int linktarget ID greater then 0
140     * @throws RuntimeException if no linktarget ID has been assigned to this $linkTarget
141     */
142    public function acquireLinkTargetId( LinkTarget $linkTarget, IDatabase $dbw ): int {
143        // allow cache to be used, because if it is in the cache, it already has a linktarget id
144        $existingLinktargetId = $this->getLinkTargetIdFromCache( $linkTarget );
145        if ( $existingLinktargetId ) {
146            return $existingLinktargetId;
147        }
148
149        // Checking primary when it doesn't exist in replica is not that useful but given
150        // the fact that failed inserts waste an auto_increment id better to avoid that.
151        $linkTargetId = $this->fetchIdFromDbPrimary( $linkTarget );
152        if ( $linkTargetId ) {
153            $this->addToClassCache( $linkTargetId, $linkTarget );
154            return $linkTargetId;
155        }
156
157        $ns = $linkTarget->getNamespace();
158        $title = $linkTarget->getDBkey();
159
160        $dbw->newInsertQueryBuilder()
161            ->insertInto( 'linktarget' )
162            ->ignore()
163            ->row( [ 'lt_namespace' => $ns, 'lt_title' => $title ] )
164            ->caller( __METHOD__ )->execute();
165
166        if ( $dbw->affectedRows() ) {
167            $linkTargetId = $dbw->insertId();
168        } else {
169            // Use LOCK IN SHARE MODE to bypass any MySQL REPEATABLE-READ snapshot.
170            $linkTargetId = $this->fetchIdFromDbPrimary( $linkTarget, true );
171            if ( !$linkTargetId ) {
172                throw new RuntimeException(
173                    "Failed to create link target ID for " .
174                    "lt_namespace={$ns} lt_title=\"{$title}\""
175                );
176            }
177        }
178        $this->addToClassCache( $linkTargetId, $linkTarget );
179
180        return $linkTargetId;
181    }
182
183    /**
184     * Find lt_id of the given $linkTarget
185     *
186     * @param LinkTarget $linkTarget
187     * @param bool $lockInShareMode
188     * @return int|null
189     */
190    private function fetchIdFromDbPrimary(
191        LinkTarget $linkTarget,
192        bool $lockInShareMode = false
193    ): ?int {
194        $queryBuilder = $this->dbProvider->getPrimaryDatabase()->newSelectQueryBuilder()
195            ->select( [ 'lt_id', 'lt_namespace', 'lt_title' ] )
196            ->from( 'linktarget' )
197            ->where( [ 'lt_namespace' => $linkTarget->getNamespace(), 'lt_title' => $linkTarget->getDBkey() ] );
198        if ( $lockInShareMode ) {
199            $queryBuilder->lockInShareMode();
200        }
201        $row = $queryBuilder->caller( __METHOD__ )->fetchRow();
202
203        if ( !$row || !$row->lt_id ) {
204            return null;
205        }
206        $this->addToClassCache( (int)$row->lt_id, $linkTarget );
207
208        return (int)$row->lt_id;
209    }
210
211    private function addToClassCache( int $id, LinkTarget $linkTarget ) {
212        $this->byIdCache[$id] = $linkTarget;
213        $this->byTitleCache[(string)$linkTarget] = $id;
214    }
215
216    /*
217     * @internal use by tests only
218     */
219    public function clearClassCache() {
220        $this->byIdCache = [];
221        $this->byTitleCache = [];
222    }
223
224    /**
225     * @param LinkTarget $linkTarget
226     * @return int|false
227     */
228    private function getLinkTargetIdFromCache( LinkTarget $linkTarget ) {
229        $linkTargetString = (string)$linkTarget;
230        if ( isset( $this->byTitleCache[$linkTargetString] ) ) {
231            return $this->byTitleCache[$linkTargetString];
232        }
233        $fname = __METHOD__;
234        $res = $this->localCache->getWithSetCallback(
235            $this->localCache->makeKey(
236                'linktargetstore-id',
237                $linkTargetString
238            ),
239            $this->localCache::TTL_HOUR,
240            function () use ( $linkTarget, $fname ) {
241                return $this->wanObjectCache->getWithSetCallback(
242                    $this->wanObjectCache->makeKey(
243                        'linktargetstore-id',
244                        (string)$linkTarget
245                    ),
246                    WANObjectCache::TTL_DAY,
247                    function () use ( $linkTarget, $fname ) {
248                        $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
249                            ->select( [ 'lt_id', 'lt_namespace', 'lt_title' ] )
250                            ->from( 'linktarget' )
251                            ->where( [
252                                'lt_namespace' => $linkTarget->getNamespace(),
253                                'lt_title' => $linkTarget->getDBkey()
254                            ] )
255                            ->caller( $fname )->fetchRow();
256                        return $row && $row->lt_id ? (int)$row->lt_id : false;
257                    }
258                );
259            }
260        );
261
262        if ( $res ) {
263            $this->addToClassCache( $res, $linkTarget );
264        }
265
266        return $res;
267    }
268
269}