3 * Manage storage of comments in the database
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.
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.
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
23 use MediaWiki\MediaWikiServices
;
24 use Wikimedia\Rdbms\IDatabase
;
27 * CommentStore handles storage of comments (edit summaries, log reasons, etc)
34 * Maximum length of a comment in UTF-8 characters. Longer comments will be truncated.
35 * @note This must be at least 255 and not greater than floor( MAX_COMMENT_LENGTH / 4 ).
37 const COMMENT_CHARACTER_LIMIT
= 500;
40 * Maximum length of a comment in bytes. Longer comments will be truncated.
41 * @note This value is determined by the size of the underlying database field,
42 * currently BLOB in MySQL/MariaDB.
44 const MAX_COMMENT_LENGTH
= 65535;
47 * Maximum length of serialized data in bytes. Longer data will result in an exception.
48 * @note This value is determined by the size of the underlying database field,
49 * currently BLOB in MySQL/MariaDB.
51 const MAX_DATA_LENGTH
= 65535;
54 * Define fields that use temporary tables for transitional purposes
55 * @var array Keys are '$key', values are arrays with four fields:
56 * - table: Temporary table name
57 * - pk: Temporary table column referring to the main table's primary key
58 * - field: Temporary table column referring comment.comment_id
59 * - joinPK: Main table's primary key
61 protected static $tempTables = [
63 'table' => 'revision_comment_temp',
64 'pk' => 'revcomment_rev',
65 'field' => 'revcomment_comment_id',
68 'img_description' => [
69 'table' => 'image_comment_temp',
70 'pk' => 'imgcomment_name',
71 'field' => 'imgcomment_description_id',
72 'joinPK' => 'img_name',
77 * Fields that formerly used $tempTables
78 * @var array Key is '$key', value is the MediaWiki version in which it was
79 * removed from $tempTables.
81 protected static $formerTempTables = [];
88 protected $key = null;
90 /** @var int One of the MIGRATION_* constants */
93 /** @var array[] Cache for `self::getJoin()` */
94 protected $joinCache = [];
96 /** @var Language Language to use for comment truncation */
100 * @param Language $lang Language to use for comment truncation. Defaults
102 * @param int $migrationStage One of the MIGRATION_* constants
104 public function __construct( Language
$lang, $migrationStage ) {
105 $this->stage
= $migrationStage;
110 * Static constructor for easier chaining
111 * @deprecated in 1.31 Should not be constructed with a $key, use CommentStore::getStore
112 * @param string $key A key such as "rev_comment" identifying the comment
113 * field being fetched.
114 * @return CommentStore
116 public static function newKey( $key ) {
117 global $wgCommentTableSchemaMigrationStage, $wgContLang;
118 // TODO uncomment once not used in extensions
119 // wfDeprecated( __METHOD__, '1.31' );
120 $store = new CommentStore( $wgContLang, $wgCommentTableSchemaMigrationStage );
127 * @deprecated in 1.31 Use DI to inject a CommentStore instance into your class.
128 * @return CommentStore
130 public static function getStore() {
131 return MediaWikiServices
::getInstance()->getCommentStore();
135 * Compat method allowing use of self::newKey until removed.
136 * @param string|null $methodKey
137 * @throws InvalidArgumentException
140 private function getKey( $methodKey = null ) {
141 $key = $this->key
!== null ?
$this->key
: $methodKey;
142 if ( $key === null ) {
143 // @codeCoverageIgnoreStart
144 throw new InvalidArgumentException( '$key should not be null' );
145 // @codeCoverageIgnoreEnd
151 * Get SELECT fields for the comment key
153 * Each resulting row should be passed to `self::getCommentLegacy()` to get the
156 * @note Use of this method may require a subsequent database query to
157 * actually fetch the comment. If possible, use `self::getJoin()` instead.
160 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
161 * @param string $key A key such as "rev_comment" identifying the comment
162 * field being fetched.
163 * @return string[] to include in the `$vars` to `IDatabase->select()`. All
164 * fields are aliased, so `+` is safe to use.
166 public function getFields( $key = null ) {
167 $key = $this->getKey( $key );
169 if ( $this->stage
=== MIGRATION_OLD
) {
170 $fields["{$key}_text"] = $key;
171 $fields["{$key}_data"] = 'NULL';
172 $fields["{$key}_cid"] = 'NULL';
174 if ( $this->stage
< MIGRATION_NEW
) {
175 $fields["{$key}_old"] = $key;
177 if ( isset( self
::$tempTables[$key] ) ) {
178 $fields["{$key}_pk"] = self
::$tempTables[$key]['joinPK'];
180 $fields["{$key}_id"] = "{$key}_id";
187 * Get SELECT fields and joins for the comment key
189 * Each resulting row should be passed to `self::getComment()` to get the
193 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
194 * @param string $key A key such as "rev_comment" identifying the comment
195 * field being fetched.
196 * @return array With three keys:
197 * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
198 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
199 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
200 * All tables, fields, and joins are aliased, so `+` is safe to use.
202 public function getJoin( $key = null ) {
203 $key = $this->getKey( $key );
204 if ( !array_key_exists( $key, $this->joinCache
) ) {
209 if ( $this->stage
=== MIGRATION_OLD
) {
210 $fields["{$key}_text"] = $key;
211 $fields["{$key}_data"] = 'NULL';
212 $fields["{$key}_cid"] = 'NULL';
214 $join = $this->stage
=== MIGRATION_NEW ?
'JOIN' : 'LEFT JOIN';
216 if ( isset( self
::$tempTables[$key] ) ) {
217 $t = self
::$tempTables[$key];
218 $alias = "temp_$key";
219 $tables[$alias] = $t['table'];
220 $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
221 $joinField = "{$alias}.{$t['field']}";
223 $joinField = "{$key}_id";
226 $alias = "comment_$key";
227 $tables[$alias] = 'comment';
228 $joins[$alias] = [ $join, "{$alias}.comment_id = {$joinField}" ];
230 if ( $this->stage
=== MIGRATION_NEW
) {
231 $fields["{$key}_text"] = "{$alias}.comment_text";
233 $fields["{$key}_text"] = "COALESCE( {$alias}.comment_text, $key )";
235 $fields["{$key}_data"] = "{$alias}.comment_data";
236 $fields["{$key}_cid"] = "{$alias}.comment_id";
239 $this->joinCache
[$key] = [
246 return $this->joinCache
[$key];
250 * Extract the comment from a row
252 * Shared implementation for getComment() and getCommentLegacy()
254 * @param IDatabase|null $db Database handle for getCommentLegacy(), or null for getComment()
255 * @param string $key A key such as "rev_comment" identifying the comment
256 * field being fetched.
257 * @param object|array $row
258 * @param bool $fallback
259 * @return CommentStoreComment
261 private function getCommentInternal( IDatabase
$db = null, $key, $row, $fallback = false ) {
263 if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) {
264 $cid = $row["{$key}_cid"] ??
null;
265 $text = $row["{$key}_text"];
266 $data = $row["{$key}_data"];
267 } elseif ( $this->stage
=== MIGRATION_OLD
) {
269 if ( $fallback && isset( $row[$key] ) ) {
270 wfLogWarning( "Using deprecated fallback handling for comment $key" );
273 wfLogWarning( "Missing {$key}_text and {$key}_data fields in row with MIGRATION_OLD" );
278 if ( isset( self
::$tempTables[$key] ) ) {
279 if ( array_key_exists( "{$key}_pk", $row ) ) {
281 throw new InvalidArgumentException(
282 "\$row does not contain fields needed for comment $key and getComment(), but "
283 . "does have fields for getCommentLegacy()"
286 $t = self
::$tempTables[$key];
287 $id = $row["{$key}_pk"];
288 $row2 = $db->selectRow(
289 [ $t['table'], 'comment' ],
290 [ 'comment_id', 'comment_text', 'comment_data' ],
294 [ 'comment' => [ 'JOIN', [ "comment_id = {$t['field']}" ] ] ]
296 } elseif ( $fallback && isset( $row[$key] ) ) {
297 wfLogWarning( "Using deprecated fallback handling for comment $key" );
298 $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
300 throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
303 if ( array_key_exists( "{$key}_id", $row ) ) {
305 throw new InvalidArgumentException(
306 "\$row does not contain fields needed for comment $key and getComment(), but "
307 . "does have fields for getCommentLegacy()"
310 $id = $row["{$key}_id"];
311 $row2 = $db->selectRow(
313 [ 'comment_id', 'comment_text', 'comment_data' ],
314 [ 'comment_id' => $id ],
317 } elseif ( $fallback && isset( $row[$key] ) ) {
318 wfLogWarning( "Using deprecated fallback handling for comment $key" );
319 $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
321 throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
326 $cid = $row2->comment_id
;
327 $text = $row2->comment_text
;
328 $data = $row2->comment_data
;
329 } elseif ( $this->stage
< MIGRATION_NEW
&& array_key_exists( "{$key}_old", $row ) ) {
331 $text = $row["{$key}_old"];
334 // @codeCoverageIgnoreStart
335 wfLogWarning( "Missing comment row for $key, id=$id" );
339 // @codeCoverageIgnoreEnd
344 if ( $data !== null ) {
345 $data = FormatJson
::decode( $data );
346 if ( !is_object( $data ) ) {
347 // @codeCoverageIgnoreStart
348 wfLogWarning( "Invalid JSON object in comment: $data" );
350 // @codeCoverageIgnoreEnd
352 $data = (array)$data;
353 if ( isset( $data['_message'] ) ) {
354 $msg = self
::decodeMessage( $data['_message'] )
355 ->setInterfaceMessageFlag( true );
357 if ( !empty( $data['_null'] ) ) {
360 foreach ( $data as $k => $v ) {
361 if ( substr( $k, 0, 1 ) === '_' ) {
369 return new CommentStoreComment( $cid, $text, $msg, $data );
373 * Extract the comment from a row
375 * Use `self::getJoin()` to ensure the row contains the needed data.
377 * If you need to fake a comment in a row for some reason, set fields
378 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
381 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
382 * @param string $key A key such as "rev_comment" identifying the comment
383 * field being fetched.
384 * @param object|array $row Result row.
385 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
386 * @return CommentStoreComment
388 public function getComment( $key, $row = null, $fallback = false ) {
389 // Compat for method sig change in 1.31 (introduction of $key)
390 if ( $this->key
!== null ) {
393 $key = $this->getKey();
395 if ( $row === null ) {
396 // @codeCoverageIgnoreStart
397 throw new InvalidArgumentException( '$row must not be null' );
398 // @codeCoverageIgnoreEnd
400 return $this->getCommentInternal( null, $key, $row, $fallback );
404 * Extract the comment from a row, with legacy lookups.
406 * If `$row` might have been generated using `self::getFields()` rather
407 * than `self::getJoin()`, use this. Prefer `self::getComment()` if you
408 * know callers used `self::getJoin()` for the row fetch.
410 * If you need to fake a comment in a row for some reason, set fields
411 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
414 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
415 * @param IDatabase $db Database handle to use for lookup
416 * @param string $key A key such as "rev_comment" identifying the comment
417 * field being fetched.
418 * @param object|array $row Result row.
419 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
420 * @return CommentStoreComment
422 public function getCommentLegacy( IDatabase
$db, $key, $row = null, $fallback = false ) {
423 // Compat for method sig change in 1.31 (introduction of $key)
424 if ( $this->key
!== null ) {
427 $key = $this->getKey();
429 if ( $row === null ) {
430 // @codeCoverageIgnoreStart
431 throw new InvalidArgumentException( '$row must not be null' );
432 // @codeCoverageIgnoreEnd
434 return $this->getCommentInternal( $db, $key, $row, $fallback );
438 * Create a new CommentStoreComment, inserting it into the database if necessary
440 * If a comment is going to be passed to `self::insert()` or the like
441 * multiple times, it will be more efficient to pass a CommentStoreComment
442 * once rather than making `self::insert()` do it every time through.
444 * @note When passing a CommentStoreComment, this may set `$comment->id` if
445 * it's not already set. If `$comment->id` is already set, it will not be
446 * verified that the specified comment actually exists or that it
447 * corresponds to the comment text, message, and/or data in the
448 * CommentStoreComment.
449 * @param IDatabase $dbw Database handle to insert on. Unused if `$comment`
450 * is a CommentStoreComment and `$comment->id` is set.
451 * @param string|Message|CommentStoreComment $comment Comment text or Message object, or
452 * a CommentStoreComment.
453 * @param array|null $data Structured data to store. Keys beginning with '_' are reserved.
454 * Ignored if $comment is a CommentStoreComment.
455 * @return CommentStoreComment
457 public function createComment( IDatabase
$dbw, $comment, array $data = null ) {
458 $comment = CommentStoreComment
::newUnsavedComment( $comment, $data );
460 # Truncate comment in a Unicode-sensitive manner
461 $comment->text
= $this->lang
->truncate( $comment->text
, self
::MAX_COMMENT_LENGTH
);
462 if ( mb_strlen( $comment->text
, 'UTF-8' ) > self
::COMMENT_CHARACTER_LIMIT
) {
463 $ellipsis = wfMessage( 'ellipsis' )->inLanguage( $this->lang
)->escaped();
464 if ( mb_strlen( $ellipsis ) >= self
::COMMENT_CHARACTER_LIMIT
) {
468 $maxLength = self
::COMMENT_CHARACTER_LIMIT
- mb_strlen( $ellipsis, 'UTF-8' );
469 $comment->text
= mb_substr( $comment->text
, 0, $maxLength, 'UTF-8' ) . $ellipsis;
472 if ( $this->stage
> MIGRATION_OLD
&& !$comment->id
) {
473 $dbData = $comment->data
;
474 if ( !$comment->message
instanceof RawMessage
) {
475 if ( $dbData === null ) {
476 $dbData = [ '_null' => true ];
478 $dbData['_message'] = self
::encodeMessage( $comment->message
);
480 if ( $dbData !== null ) {
481 $dbData = FormatJson
::encode( (object)$dbData, false, FormatJson
::ALL_OK
);
482 $len = strlen( $dbData );
483 if ( $len > self
::MAX_DATA_LENGTH
) {
484 $max = self
::MAX_DATA_LENGTH
;
485 throw new OverflowException( "Comment data is too long ($len bytes, maximum is $max)" );
489 $hash = self
::hash( $comment->text
, $dbData );
490 $comment->id
= $dbw->selectField(
494 'comment_hash' => $hash,
495 'comment_text' => $comment->text
,
496 'comment_data' => $dbData,
500 if ( !$comment->id
) {
504 'comment_hash' => $hash,
505 'comment_text' => $comment->text
,
506 'comment_data' => $dbData,
510 $comment->id
= $dbw->insertId();
518 * Implementation for `self::insert()` and `self::insertWithTempTable()`
519 * @param IDatabase $dbw
520 * @param string $key A key such as "rev_comment" identifying the comment
521 * field being fetched.
522 * @param string|Message|CommentStoreComment $comment
523 * @param array|null $data
524 * @return array [ array $fields, callable $callback ]
526 private function insertInternal( IDatabase
$dbw, $key, $comment, $data ) {
530 $comment = $this->createComment( $dbw, $comment, $data );
532 if ( $this->stage
<= MIGRATION_WRITE_BOTH
) {
533 $fields[$key] = $this->lang
->truncate( $comment->text
, 255 );
536 if ( $this->stage
>= MIGRATION_WRITE_BOTH
) {
537 if ( isset( self
::$tempTables[$key] ) ) {
538 $t = self
::$tempTables[$key];
540 $commentId = $comment->id
;
541 $callback = function ( $id ) use ( $dbw, $commentId, $t, $func ) {
546 $t['field'] => $commentId,
552 $fields["{$key}_id"] = $comment->id
;
556 return [ $fields, $callback ];
560 * Insert a comment in preparation for a row that references it
562 * @note It's recommended to include both the call to this method and the
563 * row insert in the same transaction.
566 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
567 * @param IDatabase $dbw Database handle to insert on
568 * @param string $key A key such as "rev_comment" identifying the comment
569 * field being fetched.
570 * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
571 * @param array|null $data As for `self::createComment()`
572 * @return array Fields for the insert or update
574 public function insert( IDatabase
$dbw, $key, $comment = null, $data = null ) {
575 // Compat for method sig change in 1.31 (introduction of $key)
576 if ( $this->key
!== null ) {
581 if ( $comment === null ) {
582 // @codeCoverageIgnoreStart
583 throw new InvalidArgumentException( '$comment can not be null' );
584 // @codeCoverageIgnoreEnd
587 if ( isset( self
::$tempTables[$key] ) ) {
588 throw new InvalidArgumentException( "Must use insertWithTempTable() for $key" );
591 list( $fields ) = $this->insertInternal( $dbw, $key, $comment, $data );
596 * Insert a comment in a temporary table in preparation for a row that references it
598 * This is currently needed for "rev_comment" and "img_description". In the
599 * future that requirement will be removed.
601 * @note It's recommended to include both the call to this method and the
602 * row insert in the same transaction.
605 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
606 * @param IDatabase $dbw Database handle to insert on
607 * @param string $key A key such as "rev_comment" identifying the comment
608 * field being fetched.
609 * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
610 * @param array|null $data As for `self::createComment()`
611 * @return array Two values:
612 * - array Fields for the insert or update
613 * - callable Function to call when the primary key of the row being
614 * inserted/updated is known. Pass it that primary key.
616 public function insertWithTempTable( IDatabase
$dbw, $key, $comment = null, $data = null ) {
617 // Compat for method sig change in 1.31 (introduction of $key)
618 if ( $this->key
!== null ) {
621 $key = $this->getKey();
623 if ( $comment === null ) {
624 // @codeCoverageIgnoreStart
625 throw new InvalidArgumentException( '$comment can not be null' );
626 // @codeCoverageIgnoreEnd
629 if ( isset( self
::$formerTempTables[$key] ) ) {
630 wfDeprecated( __METHOD__
. " for $key", self
::$formerTempTables[$key] );
631 } elseif ( !isset( self
::$tempTables[$key] ) ) {
632 throw new InvalidArgumentException( "Must use insert() for $key" );
635 list( $fields, $callback ) = $this->insertInternal( $dbw, $key, $comment, $data );
637 $callback = function () {
641 return [ $fields, $callback ];
645 * Encode a Message as a PHP data structure
646 * @param Message $msg
649 protected static function encodeMessage( Message
$msg ) {
650 $key = count( $msg->getKeysToTry() ) > 1 ?
$msg->getKeysToTry() : $msg->getKey();
651 $params = $msg->getParams();
652 foreach ( $params as &$param ) {
653 if ( $param instanceof Message
) {
655 'message' => self
::encodeMessage( $param )
659 array_unshift( $params, $key );
664 * Decode a message that was encoded by self::encodeMessage()
668 protected static function decodeMessage( $data ) {
669 $key = array_shift( $data );
670 foreach ( $data as &$param ) {
671 if ( is_object( $param ) ) {
672 $param = (array)$param;
674 if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) {
675 $param = self
::decodeMessage( $param['message'] );
678 return new Message( $key, $data );
682 * Hashing function for comment storage
683 * @param string $text Comment text
684 * @param string|null $data Comment data
685 * @return int 32-bit signed integer
687 public static function hash( $text, $data ) {
688 $hash = crc32( $text ) ^
crc32( (string)$data );
690 // 64-bit PHP returns an unsigned CRC, change it to signed for
691 // insertion into the database.
692 if ( $hash >= 0x80000000 ) {