Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.28% |
114 / 116 |
|
90.00% |
9 / 10 |
CRAP | |
0.00% |
0 / 1 |
CommentStore | |
99.13% |
114 / 115 |
|
90.00% |
9 / 10 |
43 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getJoin | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
getCommentInternal | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
16 | |||
getComment | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getCommentLegacy | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
createComment | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
6 | |||
insert | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
encodeMessage | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
decodeMessage | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
6.10 | |||
hash | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 |
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 | |
21 | namespace MediaWiki\CommentStore; |
22 | |
23 | use FormatJson; |
24 | use InvalidArgumentException; |
25 | use Language; |
26 | use MediaWiki\Language\RawMessage; |
27 | use MediaWiki\Message\Message; |
28 | use OverflowException; |
29 | use stdClass; |
30 | use Wikimedia\Rdbms\IDatabase; |
31 | use Wikimedia\Rdbms\IReadableDatabase; |
32 | |
33 | /** |
34 | * @defgroup CommentStore CommentStore |
35 | * |
36 | * The Comment store in MediaWiki is responsible for storing edit summaries, |
37 | * log action comments and other such short strings (referred to as "comments"). |
38 | * |
39 | * The CommentStore class handles the database abstraction for reading |
40 | * and writing comments, which are represented by CommentStoreComment objects. |
41 | * |
42 | * Data is internally stored in the `comment` table. |
43 | */ |
44 | |
45 | /** |
46 | * Handle database storage of comments such as edit summaries and log reasons. |
47 | * |
48 | * @ingroup CommentStore |
49 | * @since 1.30 |
50 | */ |
51 | class CommentStore { |
52 | |
53 | /** |
54 | * Maximum length of a comment in UTF-8 characters. Longer comments will be truncated. |
55 | * @note This must be at least 255 and not greater than floor( MAX_DATA_LENGTH / 4 ). |
56 | */ |
57 | public const COMMENT_CHARACTER_LIMIT = 500; |
58 | |
59 | /** |
60 | * Maximum length of serialized data in bytes. Longer data will result in an exception. |
61 | * @note This value is determined by the size of the underlying database field, |
62 | * currently BLOB in MySQL/MariaDB. |
63 | */ |
64 | public const MAX_DATA_LENGTH = 65535; |
65 | |
66 | /** @var array[] Cache for `self::getJoin()` */ |
67 | private $joinCache = []; |
68 | |
69 | /** @var Language Language to use for comment truncation */ |
70 | private $lang; |
71 | |
72 | /** |
73 | * @param Language $lang Language to use for comment truncation. Defaults |
74 | * to content language. |
75 | */ |
76 | public function __construct( Language $lang ) { |
77 | $this->lang = $lang; |
78 | } |
79 | |
80 | /** |
81 | * Get SELECT fields and joins for the comment key |
82 | * |
83 | * Each resulting row should be passed to `self::getComment()` to get the |
84 | * actual comment. |
85 | * |
86 | * @since 1.30 |
87 | * @since 1.31 Method signature changed, $key parameter added (required since 1.35) |
88 | * @param string $key A key such as "rev_comment" identifying the comment |
89 | * field being fetched. |
90 | * @return array[] With three keys: |
91 | * - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables` |
92 | * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields` |
93 | * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds` |
94 | * All tables, fields, and joins are aliased, so `+` is safe to use. |
95 | * @phan-return array{tables:string[],fields:string[],joins:array} |
96 | * @return-taint none |
97 | */ |
98 | public function getJoin( $key ) { |
99 | if ( !array_key_exists( $key, $this->joinCache ) ) { |
100 | $tables = []; |
101 | $fields = []; |
102 | $joins = []; |
103 | |
104 | $alias = "comment_$key"; |
105 | $tables[$alias] = 'comment'; |
106 | $joins[$alias] = [ 'JOIN', "{$alias}.comment_id = {$key}_id" ]; |
107 | |
108 | $fields["{$key}_text"] = "{$alias}.comment_text"; |
109 | $fields["{$key}_data"] = "{$alias}.comment_data"; |
110 | $fields["{$key}_cid"] = "{$alias}.comment_id"; |
111 | |
112 | $this->joinCache[$key] = [ |
113 | 'tables' => $tables, |
114 | 'fields' => $fields, |
115 | 'joins' => $joins, |
116 | ]; |
117 | } |
118 | |
119 | return $this->joinCache[$key]; |
120 | } |
121 | |
122 | /** |
123 | * Extract the comment from a row |
124 | * |
125 | * Shared implementation for getComment() and getCommentLegacy() |
126 | * |
127 | * @param IReadableDatabase|null $db Database handle for getCommentLegacy(), or null for getComment() |
128 | * @param string $key A key such as "rev_comment" identifying the comment |
129 | * field being fetched. |
130 | * @param stdClass|array $row |
131 | * @param bool $fallback |
132 | * @return CommentStoreComment |
133 | */ |
134 | private function getCommentInternal( ?IReadableDatabase $db, $key, $row, $fallback = false ) { |
135 | $row = (array)$row; |
136 | if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) { |
137 | $cid = $row["{$key}_cid"] ?? null; |
138 | $text = $row["{$key}_text"]; |
139 | $data = $row["{$key}_data"]; |
140 | } else { |
141 | $row2 = null; |
142 | if ( array_key_exists( "{$key}_id", $row ) ) { |
143 | if ( !$db ) { |
144 | throw new InvalidArgumentException( |
145 | "\$row does not contain fields needed for comment $key and getComment(), but " |
146 | . "does have fields for getCommentLegacy()" |
147 | ); |
148 | } |
149 | $id = $row["{$key}_id"]; |
150 | $row2 = $db->newSelectQueryBuilder() |
151 | ->select( [ 'comment_id', 'comment_text', 'comment_data' ] ) |
152 | ->from( 'comment' ) |
153 | ->where( [ 'comment_id' => $id ] ) |
154 | ->caller( __METHOD__ )->fetchRow(); |
155 | } |
156 | if ( $row2 === null && $fallback && isset( $row[$key] ) ) { |
157 | wfLogWarning( "Using deprecated fallback handling for comment $key" ); |
158 | $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ]; |
159 | } |
160 | if ( $row2 === null ) { |
161 | throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" ); |
162 | } |
163 | |
164 | if ( $row2 ) { |
165 | $cid = $row2->comment_id; |
166 | $text = $row2->comment_text; |
167 | $data = $row2->comment_data; |
168 | } else { |
169 | // @codeCoverageIgnoreStart |
170 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $id is set when $row2 is okay |
171 | wfLogWarning( "Missing comment row for $key, id=$id" ); |
172 | $cid = null; |
173 | $text = ''; |
174 | $data = null; |
175 | // @codeCoverageIgnoreEnd |
176 | } |
177 | } |
178 | |
179 | $msg = null; |
180 | if ( $data !== null ) { |
181 | $data = FormatJson::decode( $data, true ); |
182 | if ( !is_array( $data ) ) { |
183 | // @codeCoverageIgnoreStart |
184 | wfLogWarning( "Invalid JSON object in comment: $data" ); |
185 | $data = null; |
186 | // @codeCoverageIgnoreEnd |
187 | } else { |
188 | if ( isset( $data['_message'] ) ) { |
189 | $msg = self::decodeMessage( $data['_message'] ) |
190 | ->setInterfaceMessageFlag( true ); |
191 | } |
192 | if ( !empty( $data['_null'] ) ) { |
193 | $data = null; |
194 | } else { |
195 | foreach ( $data as $k => $v ) { |
196 | if ( substr( $k, 0, 1 ) === '_' ) { |
197 | unset( $data[$k] ); |
198 | } |
199 | } |
200 | } |
201 | } |
202 | } |
203 | |
204 | return new CommentStoreComment( $cid, $text, $msg, $data ); |
205 | } |
206 | |
207 | /** |
208 | * Extract the comment from a row |
209 | * |
210 | * Use `self::getJoin()` to ensure the row contains the needed data. |
211 | * |
212 | * If you need to fake a comment in a row for some reason, set fields |
213 | * `{$key}_text` (string) and `{$key}_data` (JSON string or null). |
214 | * |
215 | * @since 1.30 |
216 | * @since 1.31 Method signature changed, $key parameter added (required since 1.35) |
217 | * @param string $key A key such as "rev_comment" identifying the comment |
218 | * field being fetched. |
219 | * @param stdClass|array|null $row Result row. |
220 | * @param bool $fallback If true, fall back as well as possible instead of throwing an exception. |
221 | * @return CommentStoreComment |
222 | */ |
223 | public function getComment( $key, $row = null, $fallback = false ) { |
224 | if ( $row === null ) { |
225 | // @codeCoverageIgnoreStart |
226 | throw new InvalidArgumentException( '$row must not be null' ); |
227 | // @codeCoverageIgnoreEnd |
228 | } |
229 | return $this->getCommentInternal( null, $key, $row, $fallback ); |
230 | } |
231 | |
232 | /** |
233 | * Extract the comment from a row, with legacy lookups. |
234 | * |
235 | * If `$row` might have been generated using `self::getFields()` rather |
236 | * than `self::getJoin()`, use this. Prefer `self::getComment()` if you |
237 | * know callers used `self::getJoin()` for the row fetch. |
238 | * |
239 | * If you need to fake a comment in a row for some reason, set fields |
240 | * `{$key}_text` (string) and `{$key}_data` (JSON string or null). |
241 | * |
242 | * @since 1.30 |
243 | * @since 1.31 Method signature changed, $key parameter added (required since 1.35) |
244 | * @param IReadableDatabase $db Database handle to use for lookup |
245 | * @param string $key A key such as "rev_comment" identifying the comment |
246 | * field being fetched. |
247 | * @param stdClass|array|null $row Result row. |
248 | * @param bool $fallback If true, fall back as well as possible instead of throwing an exception. |
249 | * @return CommentStoreComment |
250 | */ |
251 | public function getCommentLegacy( IReadableDatabase $db, $key, $row = null, $fallback = false ) { |
252 | if ( $row === null ) { |
253 | // @codeCoverageIgnoreStart |
254 | throw new InvalidArgumentException( '$row must not be null' ); |
255 | // @codeCoverageIgnoreEnd |
256 | } |
257 | return $this->getCommentInternal( $db, $key, $row, $fallback ); |
258 | } |
259 | |
260 | /** |
261 | * Create a new CommentStoreComment, inserting it into the database if necessary |
262 | * |
263 | * If a comment is going to be passed to `self::insert()` or the like |
264 | * multiple times, it will be more efficient to pass a CommentStoreComment |
265 | * once rather than making `self::insert()` do it every time through. |
266 | * |
267 | * @note When passing a CommentStoreComment, this may set `$comment->id` if |
268 | * it's not already set. If `$comment->id` is already set, it will not be |
269 | * verified that the specified comment actually exists or that it |
270 | * corresponds to the comment text, message, and/or data in the |
271 | * CommentStoreComment. |
272 | * @param IDatabase $dbw Database handle to insert on. Unused if `$comment` |
273 | * is a CommentStoreComment and `$comment->id` is set. |
274 | * @param string|Message|CommentStoreComment $comment Comment text or Message object, or |
275 | * a CommentStoreComment. |
276 | * @param array|null $data Structured data to store. Keys beginning with '_' are reserved. |
277 | * Ignored if $comment is a CommentStoreComment. |
278 | * @return CommentStoreComment |
279 | */ |
280 | public function createComment( IDatabase $dbw, $comment, array $data = null ) { |
281 | $comment = CommentStoreComment::newUnsavedComment( $comment, $data ); |
282 | |
283 | # Truncate comment in a Unicode-sensitive manner |
284 | $comment->text = $this->lang->truncateForVisual( $comment->text, self::COMMENT_CHARACTER_LIMIT ); |
285 | |
286 | if ( !$comment->id ) { |
287 | $dbData = $comment->data; |
288 | if ( !$comment->message instanceof RawMessage ) { |
289 | $dbData ??= [ '_null' => true ]; |
290 | $dbData['_message'] = self::encodeMessage( $comment->message ); |
291 | } |
292 | if ( $dbData !== null ) { |
293 | $dbData = FormatJson::encode( (object)$dbData, false, FormatJson::ALL_OK ); |
294 | $len = strlen( $dbData ); |
295 | if ( $len > self::MAX_DATA_LENGTH ) { |
296 | $max = self::MAX_DATA_LENGTH; |
297 | throw new OverflowException( "Comment data is too long ($len bytes, maximum is $max)" ); |
298 | } |
299 | } |
300 | |
301 | $hash = self::hash( $comment->text, $dbData ); |
302 | $commentId = $dbw->newSelectQueryBuilder() |
303 | ->select( 'comment_id' ) |
304 | ->from( 'comment' ) |
305 | ->where( [ |
306 | 'comment_hash' => $hash, |
307 | 'comment_text' => $comment->text, |
308 | 'comment_data' => $dbData, |
309 | ] ) |
310 | ->caller( __METHOD__ )->fetchField(); |
311 | if ( !$commentId ) { |
312 | $dbw->newInsertQueryBuilder() |
313 | ->insertInto( 'comment' ) |
314 | ->row( [ 'comment_hash' => $hash, 'comment_text' => $comment->text, 'comment_data' => $dbData ] ) |
315 | ->caller( __METHOD__ )->execute(); |
316 | $commentId = $dbw->insertId(); |
317 | } |
318 | $comment->id = (int)$commentId; |
319 | } |
320 | |
321 | return $comment; |
322 | } |
323 | |
324 | /** |
325 | * Insert a comment in preparation for a row that references it |
326 | * |
327 | * @note It's recommended to include both the call to this method and the |
328 | * row insert in the same transaction. |
329 | * |
330 | * @since 1.30 |
331 | * @since 1.31 Method signature changed, $key parameter added (required since 1.35) |
332 | * @param IDatabase $dbw Database handle to insert on |
333 | * @param string $key A key such as "rev_comment" identifying the comment |
334 | * field being fetched. |
335 | * @param string|Message|CommentStoreComment|null $comment As for `self::createComment()` |
336 | * @param array|null $data As for `self::createComment()` |
337 | * @return array Fields for the insert or update |
338 | * @return-taint none |
339 | */ |
340 | public function insert( IDatabase $dbw, $key, $comment = null, $data = null ) { |
341 | if ( $comment === null ) { |
342 | // @codeCoverageIgnoreStart |
343 | throw new InvalidArgumentException( '$comment can not be null' ); |
344 | // @codeCoverageIgnoreEnd |
345 | } |
346 | |
347 | $comment = $this->createComment( $dbw, $comment, $data ); |
348 | return [ "{$key}_id" => $comment->id ]; |
349 | } |
350 | |
351 | /** |
352 | * Encode a Message as a PHP data structure |
353 | * @param Message $msg |
354 | * @return array |
355 | */ |
356 | private static function encodeMessage( Message $msg ) { |
357 | $key = count( $msg->getKeysToTry() ) > 1 ? $msg->getKeysToTry() : $msg->getKey(); |
358 | $params = $msg->getParams(); |
359 | foreach ( $params as &$param ) { |
360 | if ( $param instanceof Message ) { |
361 | $param = [ |
362 | 'message' => self::encodeMessage( $param ) |
363 | ]; |
364 | } |
365 | } |
366 | array_unshift( $params, $key ); |
367 | return $params; |
368 | } |
369 | |
370 | /** |
371 | * Decode a message that was encoded by self::encodeMessage() |
372 | * @param array $data |
373 | * @return Message |
374 | */ |
375 | private static function decodeMessage( $data ) { |
376 | $key = array_shift( $data ); |
377 | foreach ( $data as &$param ) { |
378 | if ( is_object( $param ) ) { |
379 | $param = (array)$param; |
380 | } |
381 | if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) { |
382 | $param = self::decodeMessage( $param['message'] ); |
383 | } |
384 | } |
385 | return new Message( $key, $data ); |
386 | } |
387 | |
388 | /** |
389 | * Hashing function for comment storage |
390 | * @param string $text Comment text |
391 | * @param string|null $data Comment data |
392 | * @return int 32-bit signed integer |
393 | */ |
394 | public static function hash( $text, $data ) { |
395 | $hash = crc32( $text ) ^ crc32( (string)$data ); |
396 | |
397 | // 64-bit PHP returns an unsigned CRC, change it to signed for |
398 | // insertion into the database. |
399 | if ( $hash >= 0x80000000 ) { |
400 | $hash |= -1 << 32; |
401 | } |
402 | |
403 | return $hash; |
404 | } |
405 | |
406 | } |
407 | |
408 | /** @deprecated class alias since 1.40 */ |
409 | class_alias( CommentStore::class, 'CommentStore' ); |