Merge "RCFilters: rephrase the feedback link text"
[lhc/web/wiklou.git] / includes / CommentStore.php
1 <?php
2 /**
3 * Manage storage of comments in the database
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 use Wikimedia\Rdbms\IDatabase;
24
25 /**
26 * CommentStore handles storage of comments (edit summaries, log reasons, etc)
27 * in the database.
28 * @since 1.30
29 */
30 class CommentStore {
31
32 /**
33 * Define fields that use temporary tables for transitional purposes
34 * @var array Keys are '$key', values are arrays with four fields:
35 * - table: Temporary table name
36 * - pk: Temporary table column referring to the main table's primary key
37 * - field: Temporary table column referring comment.comment_id
38 * - joinPK: Main table's primary key
39 */
40 protected static $tempTables = [
41 'rev_comment' => [
42 'table' => 'revision_comment_temp',
43 'pk' => 'revcomment_rev',
44 'field' => 'revcomment_comment_id',
45 'joinPK' => 'rev_id',
46 ],
47 'img_description' => [
48 'table' => 'image_comment_temp',
49 'pk' => 'imgcomment_name',
50 'field' => 'imgcomment_description_id',
51 'joinPK' => 'img_name',
52 ],
53 ];
54
55 /**
56 * Fields that formerly used $tempTables
57 * @var array Key is '$key', value is the MediaWiki version in which it was
58 * removed from $tempTables.
59 */
60 protected static $formerTempTables = [];
61
62 /** @var string */
63 protected $key;
64
65 /** @var int One of the MIGRATION_* constants */
66 protected $stage;
67
68 /** @var array|null Cache for `self::getJoin()` */
69 protected $joinCache = null;
70
71 /**
72 * @param string $key A key such as "rev_comment" identifying the comment
73 * field being fetched.
74 */
75 public function __construct( $key ) {
76 global $wgCommentTableSchemaMigrationStage;
77
78 $this->key = $key;
79 $this->stage = $wgCommentTableSchemaMigrationStage;
80 }
81
82 /**
83 * Static constructor for easier chaining
84 * @param string $key A key such as "rev_comment" identifying the comment
85 * field being fetched.
86 * @return CommentStore
87 */
88 public static function newKey( $key ) {
89 return new CommentStore( $key );
90 }
91
92 /**
93 * Get SELECT fields for the comment key
94 *
95 * Each resulting row should be passed to `self::getCommentLegacy()` to get the
96 * actual comment.
97 *
98 * @note Use of this method may require a subsequent database query to
99 * actually fetch the comment. If possible, use `self::getJoin()` instead.
100 * @return string[] to include in the `$vars` to `IDatabase->select()`. All
101 * fields are aliased, so `+` is safe to use.
102 */
103 public function getFields() {
104 $fields = [];
105 if ( $this->stage === MIGRATION_OLD ) {
106 $fields["{$this->key}_text"] = $this->key;
107 $fields["{$this->key}_data"] = 'NULL';
108 $fields["{$this->key}_cid"] = 'NULL';
109 } else {
110 if ( $this->stage < MIGRATION_NEW ) {
111 $fields["{$this->key}_old"] = $this->key;
112 }
113 if ( isset( self::$tempTables[$this->key] ) ) {
114 $fields["{$this->key}_pk"] = self::$tempTables[$this->key]['joinPK'];
115 } else {
116 $fields["{$this->key}_id"] = "{$this->key}_id";
117 }
118 }
119 return $fields;
120 }
121
122 /**
123 * Get SELECT fields and joins for the comment key
124 *
125 * Each resulting row should be passed to `self::getComment()` to get the
126 * actual comment.
127 *
128 * @return array With three keys:
129 * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
130 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
131 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
132 * All tables, fields, and joins are aliased, so `+` is safe to use.
133 */
134 public function getJoin() {
135 if ( $this->joinCache === null ) {
136 $tables = [];
137 $fields = [];
138 $joins = [];
139
140 if ( $this->stage === MIGRATION_OLD ) {
141 $fields["{$this->key}_text"] = $this->key;
142 $fields["{$this->key}_data"] = 'NULL';
143 $fields["{$this->key}_cid"] = 'NULL';
144 } else {
145 $join = $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN';
146
147 if ( isset( self::$tempTables[$this->key] ) ) {
148 $t = self::$tempTables[$this->key];
149 $alias = "temp_$this->key";
150 $tables[$alias] = $t['table'];
151 $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
152 $joinField = "{$alias}.{$t['field']}";
153 } else {
154 $joinField = "{$this->key}_id";
155 }
156
157 $alias = "comment_$this->key";
158 $tables[$alias] = 'comment';
159 $joins[$alias] = [ $join, "{$alias}.comment_id = {$joinField}" ];
160
161 if ( $this->stage === MIGRATION_NEW ) {
162 $fields["{$this->key}_text"] = "{$alias}.comment_text";
163 } else {
164 $fields["{$this->key}_text"] = "COALESCE( {$alias}.comment_text, $this->key )";
165 }
166 $fields["{$this->key}_data"] = "{$alias}.comment_data";
167 $fields["{$this->key}_cid"] = "{$alias}.comment_id";
168 }
169
170 $this->joinCache = [
171 'tables' => $tables,
172 'fields' => $fields,
173 'joins' => $joins,
174 ];
175 }
176
177 return $this->joinCache;
178 }
179
180 /**
181 * Extract the comment from a row
182 *
183 * Shared implementation for getComment() and getCommentLegacy()
184 *
185 * @param IDatabase|null $db Database handle for getCommentLegacy(), or null for getComment()
186 * @param object|array $row
187 * @param bool $fallback
188 * @return CommentStoreComment
189 */
190 private function getCommentInternal( IDatabase $db = null, $row, $fallback = false ) {
191 $key = $this->key;
192 $row = (array)$row;
193 if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) {
194 $cid = isset( $row["{$key}_cid"] ) ? $row["{$key}_cid"] : null;
195 $text = $row["{$key}_text"];
196 $data = $row["{$key}_data"];
197 } elseif ( $this->stage === MIGRATION_OLD ) {
198 $cid = null;
199 if ( $fallback && isset( $row[$key] ) ) {
200 wfLogWarning( "Using deprecated fallback handling for comment $key" );
201 $text = $row[$key];
202 } else {
203 wfLogWarning( "Missing {$key}_text and {$key}_data fields in row with MIGRATION_OLD" );
204 $text = '';
205 }
206 $data = null;
207 } else {
208 if ( isset( self::$tempTables[$key] ) ) {
209 if ( array_key_exists( "{$key}_pk", $row ) ) {
210 if ( !$db ) {
211 throw new InvalidArgumentException(
212 "\$row does not contain fields needed for comment $key and getComment(), but "
213 . "does have fields for getCommentLegacy()"
214 );
215 }
216 $t = self::$tempTables[$key];
217 $id = $row["{$key}_pk"];
218 $row2 = $db->selectRow(
219 [ $t['table'], 'comment' ],
220 [ 'comment_id', 'comment_text', 'comment_data' ],
221 [ $t['pk'] => $id ],
222 __METHOD__,
223 [],
224 [ 'comment' => [ 'JOIN', [ "comment_id = {$t['field']}" ] ] ]
225 );
226 } elseif ( $fallback && isset( $row[$key] ) ) {
227 wfLogWarning( "Using deprecated fallback handling for comment $key" );
228 $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
229 } else {
230 throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
231 }
232 } else {
233 if ( array_key_exists( "{$key}_id", $row ) ) {
234 if ( !$db ) {
235 throw new InvalidArgumentException(
236 "\$row does not contain fields needed for comment $key and getComment(), but "
237 . "does have fields for getCommentLegacy()"
238 );
239 }
240 $id = $row["{$key}_id"];
241 $row2 = $db->selectRow(
242 'comment',
243 [ 'comment_id', 'comment_text', 'comment_data' ],
244 [ 'comment_id' => $id ],
245 __METHOD__
246 );
247 } elseif ( $fallback && isset( $row[$key] ) ) {
248 wfLogWarning( "Using deprecated fallback handling for comment $key" );
249 $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
250 } else {
251 throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
252 }
253 }
254
255 if ( $row2 ) {
256 $cid = $row2->comment_id;
257 $text = $row2->comment_text;
258 $data = $row2->comment_data;
259 } elseif ( $this->stage < MIGRATION_NEW && array_key_exists( "{$key}_old", $row ) ) {
260 $cid = null;
261 $text = $row["{$key}_old"];
262 $data = null;
263 } else {
264 // @codeCoverageIgnoreStart
265 wfLogWarning( "Missing comment row for $key, id=$id" );
266 $cid = null;
267 $text = '';
268 $data = null;
269 // @codeCoverageIgnoreEnd
270 }
271 }
272
273 $msg = null;
274 if ( $data !== null ) {
275 $data = FormatJson::decode( $data );
276 if ( !is_object( $data ) ) {
277 // @codeCoverageIgnoreStart
278 wfLogWarning( "Invalid JSON object in comment: $data" );
279 $data = null;
280 // @codeCoverageIgnoreEnd
281 } else {
282 $data = (array)$data;
283 if ( isset( $data['_message'] ) ) {
284 $msg = self::decodeMessage( $data['_message'] )
285 ->setInterfaceMessageFlag( true );
286 }
287 if ( !empty( $data['_null'] ) ) {
288 $data = null;
289 } else {
290 foreach ( $data as $k => $v ) {
291 if ( substr( $k, 0, 1 ) === '_' ) {
292 unset( $data[$k] );
293 }
294 }
295 }
296 }
297 }
298
299 return new CommentStoreComment( $cid, $text, $msg, $data );
300 }
301
302 /**
303 * Extract the comment from a row
304 *
305 * Use `self::getJoin()` to ensure the row contains the needed data.
306 *
307 * If you need to fake a comment in a row for some reason, set fields
308 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
309 *
310 * @param object|array $row Result row.
311 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
312 * @return CommentStoreComment
313 */
314 public function getComment( $row, $fallback = false ) {
315 return $this->getCommentInternal( null, $row, $fallback );
316 }
317
318 /**
319 * Extract the comment from a row, with legacy lookups.
320 *
321 * If `$row` might have been generated using `self::getFields()` rather
322 * than `self::getJoin()`, use this. Prefer `self::getComment()` if you
323 * know callers used `self::getJoin()` for the row fetch.
324 *
325 * If you need to fake a comment in a row for some reason, set fields
326 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
327 *
328 * @param IDatabase $db Database handle to use for lookup
329 * @param object|array $row Result row.
330 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
331 * @return CommentStoreComment
332 */
333 public function getCommentLegacy( IDatabase $db, $row, $fallback = false ) {
334 return $this->getCommentInternal( $db, $row, $fallback );
335 }
336
337 /**
338 * Create a new CommentStoreComment, inserting it into the database if necessary
339 *
340 * If a comment is going to be passed to `self::insert()` or the like
341 * multiple times, it will be more efficient to pass a CommentStoreComment
342 * once rather than making `self::insert()` do it every time through.
343 *
344 * @note When passing a CommentStoreComment, this may set `$comment->id` if
345 * it's not already set. If `$comment->id` is already set, it will not be
346 * verified that the specified comment actually exists or that it
347 * corresponds to the comment text, message, and/or data in the
348 * CommentStoreComment.
349 * @param IDatabase $dbw Database handle to insert on. Unused if `$comment`
350 * is a CommentStoreComment and `$comment->id` is set.
351 * @param string|Message|CommentStoreComment $comment Comment text or Message object, or
352 * a CommentStoreComment.
353 * @param array|null $data Structured data to store. Keys beginning with '_' are reserved.
354 * Ignored if $comment is a CommentStoreComment.
355 * @return CommentStoreComment
356 */
357 public function createComment( IDatabase $dbw, $comment, array $data = null ) {
358 global $wgContLang;
359
360 if ( !$comment instanceof CommentStoreComment ) {
361 if ( $data !== null ) {
362 foreach ( $data as $k => $v ) {
363 if ( substr( $k, 0, 1 ) === '_' ) {
364 throw new InvalidArgumentException( 'Keys in $data beginning with "_" are reserved' );
365 }
366 }
367 }
368 if ( $comment instanceof Message ) {
369 $message = clone $comment;
370 $text = $message->inLanguage( $wgContLang ) // Avoid $wgForceUIMsgAsContentMsg
371 ->setInterfaceMessageFlag( true )
372 ->text();
373 $comment = new CommentStoreComment( null, $text, $message, $data );
374 } else {
375 $comment = new CommentStoreComment( null, $comment, null, $data );
376 }
377 }
378
379 if ( $this->stage > MIGRATION_OLD && !$comment->id ) {
380 $dbData = $comment->data;
381 if ( !$comment->message instanceof RawMessage ) {
382 if ( $dbData === null ) {
383 $dbData = [ '_null' => true ];
384 }
385 $dbData['_message'] = self::encodeMessage( $comment->message );
386 }
387 if ( $dbData !== null ) {
388 $dbData = FormatJson::encode( (object)$dbData, false, FormatJson::ALL_OK );
389 }
390
391 $hash = self::hash( $comment->text, $dbData );
392 $comment->id = $dbw->selectField(
393 'comment',
394 'comment_id',
395 [
396 'comment_hash' => $hash,
397 'comment_text' => $comment->text,
398 'comment_data' => $dbData,
399 ],
400 __METHOD__
401 );
402 if ( !$comment->id ) {
403 $comment->id = $dbw->nextSequenceValue( 'comment_comment_id_seq' );
404 $dbw->insert(
405 'comment',
406 [
407 'comment_id' => $comment->id,
408 'comment_hash' => $hash,
409 'comment_text' => $comment->text,
410 'comment_data' => $dbData,
411 ],
412 __METHOD__
413 );
414 $comment->id = $dbw->insertId();
415 }
416 }
417
418 return $comment;
419 }
420
421 /**
422 * Implementation for `self::insert()` and `self::insertWithTempTable()`
423 * @param IDatabase $dbw
424 * @param string|Message|CommentStoreComment $comment
425 * @param array|null $data
426 * @return array [ array $fields, callable $callback ]
427 */
428 private function insertInternal( IDatabase $dbw, $comment, $data ) {
429 $fields = [];
430 $callback = null;
431
432 $comment = $this->createComment( $dbw, $comment, $data );
433
434 if ( $this->stage <= MIGRATION_WRITE_BOTH ) {
435 $fields[$this->key] = $comment->text;
436 }
437
438 if ( $this->stage >= MIGRATION_WRITE_BOTH ) {
439 if ( isset( self::$tempTables[$this->key] ) ) {
440 $t = self::$tempTables[$this->key];
441 $func = __METHOD__;
442 $commentId = $comment->id;
443 $callback = function ( $id ) use ( $dbw, $commentId, $t, $func ) {
444 $dbw->insert(
445 $t['table'],
446 [
447 $t['pk'] => $id,
448 $t['field'] => $commentId,
449 ],
450 $func
451 );
452 };
453 } else {
454 $fields["{$this->key}_id"] = $comment->id;
455 }
456 }
457
458 return [ $fields, $callback ];
459 }
460
461 /**
462 * Prepare for the insertion of a row with a comment
463 *
464 * @note It's recommended to include both the call to this method and the
465 * row insert in the same transaction.
466 * @param IDatabase $dbw Database handle to insert on
467 * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
468 * @param array|null $data As for `self::createComment()`
469 * @return array Fields for the insert or update
470 */
471 public function insert( IDatabase $dbw, $comment, $data = null ) {
472 if ( isset( self::$tempTables[$this->key] ) ) {
473 throw new InvalidArgumentException( "Must use insertWithTempTable() for $this->key" );
474 }
475
476 list( $fields ) = $this->insertInternal( $dbw, $comment, $data );
477 return $fields;
478 }
479
480 /**
481 * Prepare for the insertion of a row with a comment and temporary table
482 *
483 * This is currently needed for "rev_comment" and "img_description". In the
484 * future that requirement will be removed.
485 *
486 * @note It's recommended to include both the call to this method and the
487 * row insert in the same transaction.
488 * @param IDatabase $dbw Database handle to insert on
489 * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
490 * @param array|null $data As for `self::createComment()`
491 * @return array Two values:
492 * - array Fields for the insert or update
493 * - callable Function to call when the primary key of the row being
494 * inserted/updated is known. Pass it that primary key.
495 */
496 public function insertWithTempTable( IDatabase $dbw, $comment, $data = null ) {
497 if ( isset( self::$formerTempTables[$this->key] ) ) {
498 wfDeprecated( __METHOD__ . " for $this->key", self::$formerTempTables[$this->key] );
499 } elseif ( !isset( self::$tempTables[$this->key] ) ) {
500 throw new InvalidArgumentException( "Must use insert() for $this->key" );
501 }
502
503 list( $fields, $callback ) = $this->insertInternal( $dbw, $comment, $data );
504 if ( !$callback ) {
505 $callback = function () {
506 // Do nothing.
507 };
508 }
509 return [ $fields, $callback ];
510 }
511
512 /**
513 * Encode a Message as a PHP data structure
514 * @param Message $msg
515 * @return array
516 */
517 protected static function encodeMessage( Message $msg ) {
518 $key = count( $msg->getKeysToTry() ) > 1 ? $msg->getKeysToTry() : $msg->getKey();
519 $params = $msg->getParams();
520 foreach ( $params as &$param ) {
521 if ( $param instanceof Message ) {
522 $param = [
523 'message' => self::encodeMessage( $param )
524 ];
525 }
526 }
527 array_unshift( $params, $key );
528 return $params;
529 }
530
531 /**
532 * Decode a message that was encoded by self::encodeMessage()
533 * @param array $data
534 * @return Message
535 */
536 protected static function decodeMessage( $data ) {
537 $key = array_shift( $data );
538 foreach ( $data as &$param ) {
539 if ( is_object( $param ) ) {
540 $param = (array)$param;
541 }
542 if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) {
543 $param = self::decodeMessage( $param['message'] );
544 }
545 }
546 return new Message( $key, $data );
547 }
548
549 /**
550 * Hashing function for comment storage
551 * @param string $text Comment text
552 * @param string|null $data Comment data
553 * @return int 32-bit signed integer
554 */
555 public static function hash( $text, $data ) {
556 $hash = crc32( $text ) ^ crc32( (string)$data );
557
558 // 64-bit PHP returns an unsigned CRC, change it to signed for
559 // insertion into the database.
560 if ( $hash >= 0x80000000 ) {
561 $hash |= -1 << 32;
562 }
563
564 return $hash;
565 }
566
567 }