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 these possible 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
60 * - stage: Migration stage
61 * - deprecatedIn: Version when using insertWithTempTable() was deprecated
63 protected $tempTables = [
65 'table' => 'revision_comment_temp',
66 'pk' => 'revcomment_rev',
67 'field' => 'revcomment_comment_id',
69 'stage' => MIGRATION_OLD
,
70 'deprecatedIn' => null,
72 'img_description' => [
73 'stage' => MIGRATION_NEW
,
74 'deprecatedIn' => '1.32',
83 protected $key = null;
86 * @var int One of the MIGRATION_* constants, or an appropriate combination
87 * of SCHEMA_COMPAT_* constants.
88 * @todo Deprecate and remove once extensions seem unlikely to need to use
89 * it for migration anymore.
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
101 * to content language.
102 * @param int $stage One of the MIGRATION_* constants, or an appropriate
103 * combination of SCHEMA_COMPAT_* constants. Always MIGRATION_NEW for
104 * MediaWiki core since 1.33.
106 public function __construct( Language
$lang, $stage ) {
107 if ( ( $stage & SCHEMA_COMPAT_WRITE_BOTH
) === 0 ) {
108 throw new InvalidArgumentException( '$stage must include a write mode' );
110 if ( ( $stage & SCHEMA_COMPAT_READ_BOTH
) === 0 ) {
111 throw new InvalidArgumentException( '$stage must include a read mode' );
114 $this->stage
= $stage;
119 * Static constructor for easier chaining
120 * @deprecated in 1.31 Should not be constructed with a $key, use CommentStore::getStore
121 * @param string $key A key such as "rev_comment" identifying the comment
122 * field being fetched.
123 * @return CommentStore
125 public static function newKey( $key ) {
126 wfDeprecated( __METHOD__
, '1.31' );
127 $store = new CommentStore(
128 MediaWikiServices
::getInstance()->getContentLanguage(), MIGRATION_NEW
136 * @deprecated in 1.31 Use DI to inject a CommentStore instance into your class.
137 * @return CommentStore
139 public static function getStore() {
140 return MediaWikiServices
::getInstance()->getCommentStore();
144 * Compat method allowing use of self::newKey until removed.
145 * @param string|null $methodKey
146 * @throws InvalidArgumentException
149 private function getKey( $methodKey = null ) {
150 $key = $this->key ??
$methodKey;
151 if ( $key === null ) {
152 // @codeCoverageIgnoreStart
153 throw new InvalidArgumentException( '$key should not be null' );
154 // @codeCoverageIgnoreEnd
160 * Get SELECT fields for the comment key
162 * Each resulting row should be passed to `self::getCommentLegacy()` to get the
165 * @note Use of this method may require a subsequent database query to
166 * actually fetch the comment. If possible, use `self::getJoin()` instead.
169 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
170 * @param string|null $key A key such as "rev_comment" identifying the comment
171 * field being fetched.
172 * @return string[] to include in the `$vars` to `IDatabase->select()`. All
173 * fields are aliased, so `+` is safe to use.
175 public function getFields( $key = null ) {
176 $key = $this->getKey( $key );
178 if ( ( $this->stage
& SCHEMA_COMPAT_READ_BOTH
) === SCHEMA_COMPAT_READ_OLD
) {
179 $fields["{$key}_text"] = $key;
180 $fields["{$key}_data"] = 'NULL';
181 $fields["{$key}_cid"] = 'NULL';
182 } else { // READ_BOTH or READ_NEW
183 if ( $this->stage
& SCHEMA_COMPAT_READ_OLD
) {
184 $fields["{$key}_old"] = $key;
187 $tempTableStage = isset( $this->tempTables
[$key] )
188 ?
$this->tempTables
[$key]['stage'] : MIGRATION_NEW
;
189 if ( $tempTableStage & SCHEMA_COMPAT_READ_OLD
) {
190 $fields["{$key}_pk"] = $this->tempTables
[$key]['joinPK'];
192 if ( $tempTableStage & SCHEMA_COMPAT_READ_NEW
) {
193 $fields["{$key}_id"] = "{$key}_id";
200 * Get SELECT fields and joins for the comment key
202 * Each resulting row should be passed to `self::getComment()` to get the
206 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
207 * @param string|null $key A key such as "rev_comment" identifying the comment
208 * field being fetched.
209 * @return array[] With three keys:
210 * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
211 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
212 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
213 * All tables, fields, and joins are aliased, so `+` is safe to use.
214 * @phan-return array{tables:string[],fields:string[],joins:array}
216 public function getJoin( $key = null ) {
217 $key = $this->getKey( $key );
218 if ( !array_key_exists( $key, $this->joinCache
) ) {
223 if ( ( $this->stage
& SCHEMA_COMPAT_READ_BOTH
) === SCHEMA_COMPAT_READ_OLD
) {
224 $fields["{$key}_text"] = $key;
225 $fields["{$key}_data"] = 'NULL';
226 $fields["{$key}_cid"] = 'NULL';
227 } else { // READ_BOTH or READ_NEW
228 $join = ( $this->stage
& SCHEMA_COMPAT_READ_OLD
) ?
'LEFT JOIN' : 'JOIN';
230 $tempTableStage = isset( $this->tempTables
[$key] )
231 ?
$this->tempTables
[$key]['stage'] : MIGRATION_NEW
;
232 if ( $tempTableStage & SCHEMA_COMPAT_READ_OLD
) {
233 $t = $this->tempTables
[$key];
234 $alias = "temp_$key";
235 $tables[$alias] = $t['table'];
236 $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
237 if ( ( $tempTableStage & SCHEMA_COMPAT_READ_BOTH
) === SCHEMA_COMPAT_READ_OLD
) {
238 $joinField = "{$alias}.{$t['field']}";
240 // Nothing hits this code path for now, but will in the future when we set
241 // $this->tempTables['rev_comment']['stage'] to MIGRATION_WRITE_NEW while
242 // merging revision_comment_temp into revision.
243 // @codeCoverageIgnoreStart
244 $joins[$alias][0] = 'LEFT JOIN';
245 $joinField = "(CASE WHEN {$key}_id != 0 THEN {$key}_id ELSE {$alias}.{$t['field']} END)";
246 throw new LogicException( 'Nothing should reach this code path at this time' );
247 // @codeCoverageIgnoreEnd
250 $joinField = "{$key}_id";
253 $alias = "comment_$key";
254 $tables[$alias] = 'comment';
255 $joins[$alias] = [ $join, "{$alias}.comment_id = {$joinField}" ];
257 if ( ( $this->stage
& SCHEMA_COMPAT_READ_BOTH
) === SCHEMA_COMPAT_READ_NEW
) {
258 $fields["{$key}_text"] = "{$alias}.comment_text";
260 $fields["{$key}_text"] = "COALESCE( {$alias}.comment_text, $key )";
262 $fields["{$key}_data"] = "{$alias}.comment_data";
263 $fields["{$key}_cid"] = "{$alias}.comment_id";
266 $this->joinCache
[$key] = [
273 return $this->joinCache
[$key];
277 * Extract the comment from a row
279 * Shared implementation for getComment() and getCommentLegacy()
281 * @param IDatabase|null $db Database handle for getCommentLegacy(), or null for getComment()
282 * @param string $key A key such as "rev_comment" identifying the comment
283 * field being fetched.
284 * @param object|array $row
285 * @param bool $fallback
286 * @return CommentStoreComment
288 private function getCommentInternal( IDatabase
$db = null, $key, $row, $fallback = false ) {
290 if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) {
291 $cid = $row["{$key}_cid"] ??
null;
292 $text = $row["{$key}_text"];
293 $data = $row["{$key}_data"];
294 } elseif ( ( $this->stage
& SCHEMA_COMPAT_READ_BOTH
) === SCHEMA_COMPAT_READ_OLD
) {
296 if ( $fallback && isset( $row[$key] ) ) {
297 wfLogWarning( "Using deprecated fallback handling for comment $key" );
301 "Missing {$key}_text and {$key}_data fields in row with MIGRATION_OLD / READ_OLD"
307 $tempTableStage = isset( $this->tempTables
[$key] )
308 ?
$this->tempTables
[$key]['stage'] : MIGRATION_NEW
;
310 if ( ( $tempTableStage & SCHEMA_COMPAT_READ_NEW
) && array_key_exists( "{$key}_id", $row ) ) {
312 throw new InvalidArgumentException(
313 "\$row does not contain fields needed for comment $key and getComment(), but "
314 . "does have fields for getCommentLegacy()"
317 $id = $row["{$key}_id"];
318 $row2 = $db->selectRow(
320 [ 'comment_id', 'comment_text', 'comment_data' ],
321 [ 'comment_id' => $id ],
325 if ( !$row2 && ( $tempTableStage & SCHEMA_COMPAT_READ_OLD
) &&
326 array_key_exists( "{$key}_pk", $row )
329 throw new InvalidArgumentException(
330 "\$row does not contain fields needed for comment $key and getComment(), but "
331 . "does have fields for getCommentLegacy()"
334 $t = $this->tempTables
[$key];
335 $id = $row["{$key}_pk"];
336 $row2 = $db->selectRow(
337 [ $t['table'], 'comment' ],
338 [ 'comment_id', 'comment_text', 'comment_data' ],
342 [ 'comment' => [ 'JOIN', [ "comment_id = {$t['field']}" ] ] ]
345 if ( $row2 === null && $fallback && isset( $row[$key] ) ) {
346 wfLogWarning( "Using deprecated fallback handling for comment $key" );
347 $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
349 if ( $row2 === null ) {
350 throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
354 $cid = $row2->comment_id
;
355 $text = $row2->comment_text
;
356 $data = $row2->comment_data
;
357 } elseif ( ( $this->stage
& SCHEMA_COMPAT_READ_OLD
) &&
358 array_key_exists( "{$key}_old", $row )
361 $text = $row["{$key}_old"];
364 // @codeCoverageIgnoreStart
365 wfLogWarning( "Missing comment row for $key, id=$id" );
369 // @codeCoverageIgnoreEnd
374 if ( $data !== null ) {
375 $data = FormatJson
::decode( $data, true );
376 if ( !is_array( $data ) ) {
377 // @codeCoverageIgnoreStart
378 wfLogWarning( "Invalid JSON object in comment: $data" );
380 // @codeCoverageIgnoreEnd
382 if ( isset( $data['_message'] ) ) {
383 $msg = self
::decodeMessage( $data['_message'] )
384 ->setInterfaceMessageFlag( true );
386 if ( !empty( $data['_null'] ) ) {
389 foreach ( $data as $k => $v ) {
390 if ( substr( $k, 0, 1 ) === '_' ) {
398 return new CommentStoreComment( $cid, $text, $msg, $data );
402 * Extract the comment from a row
404 * Use `self::getJoin()` to ensure the row contains the needed data.
406 * If you need to fake a comment in a row for some reason, set fields
407 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
410 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
411 * @param string $key A key such as "rev_comment" identifying the comment
412 * field being fetched.
413 * @param object|array|null $row Result row.
414 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
415 * @return CommentStoreComment
417 public function getComment( $key, $row = null, $fallback = false ) {
418 // Compat for method sig change in 1.31 (introduction of $key)
419 if ( $this->key
!== null ) {
422 $key = $this->getKey();
424 if ( $row === null ) {
425 // @codeCoverageIgnoreStart
426 throw new InvalidArgumentException( '$row must not be null' );
427 // @codeCoverageIgnoreEnd
429 return $this->getCommentInternal( null, $key, $row, $fallback );
433 * Extract the comment from a row, with legacy lookups.
435 * If `$row` might have been generated using `self::getFields()` rather
436 * than `self::getJoin()`, use this. Prefer `self::getComment()` if you
437 * know callers used `self::getJoin()` for the row fetch.
439 * If you need to fake a comment in a row for some reason, set fields
440 * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
443 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
444 * @param IDatabase $db Database handle to use for lookup
445 * @param string $key A key such as "rev_comment" identifying the comment
446 * field being fetched.
447 * @param object|array|null $row Result row.
448 * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
449 * @return CommentStoreComment
451 public function getCommentLegacy( IDatabase
$db, $key, $row = null, $fallback = false ) {
452 // Compat for method sig change in 1.31 (introduction of $key)
453 if ( $this->key
!== null ) {
456 $key = $this->getKey();
458 if ( $row === null ) {
459 // @codeCoverageIgnoreStart
460 throw new InvalidArgumentException( '$row must not be null' );
461 // @codeCoverageIgnoreEnd
463 return $this->getCommentInternal( $db, $key, $row, $fallback );
467 * Create a new CommentStoreComment, inserting it into the database if necessary
469 * If a comment is going to be passed to `self::insert()` or the like
470 * multiple times, it will be more efficient to pass a CommentStoreComment
471 * once rather than making `self::insert()` do it every time through.
473 * @note When passing a CommentStoreComment, this may set `$comment->id` if
474 * it's not already set. If `$comment->id` is already set, it will not be
475 * verified that the specified comment actually exists or that it
476 * corresponds to the comment text, message, and/or data in the
477 * CommentStoreComment.
478 * @param IDatabase $dbw Database handle to insert on. Unused if `$comment`
479 * is a CommentStoreComment and `$comment->id` is set.
480 * @param string|Message|CommentStoreComment $comment Comment text or Message object, or
481 * a CommentStoreComment.
482 * @param array|null $data Structured data to store. Keys beginning with '_' are reserved.
483 * Ignored if $comment is a CommentStoreComment.
484 * @return CommentStoreComment
486 public function createComment( IDatabase
$dbw, $comment, array $data = null ) {
487 $comment = CommentStoreComment
::newUnsavedComment( $comment, $data );
489 # Truncate comment in a Unicode-sensitive manner
490 $comment->text
= $this->lang
->truncateForVisual( $comment->text
, self
::COMMENT_CHARACTER_LIMIT
);
492 if ( ( $this->stage
& SCHEMA_COMPAT_WRITE_NEW
) && !$comment->id
) {
493 $dbData = $comment->data
;
494 if ( !$comment->message
instanceof RawMessage
) {
495 if ( $dbData === null ) {
496 $dbData = [ '_null' => true ];
498 $dbData['_message'] = self
::encodeMessage( $comment->message
);
500 if ( $dbData !== null ) {
501 $dbData = FormatJson
::encode( (object)$dbData, false, FormatJson
::ALL_OK
);
502 $len = strlen( $dbData );
503 if ( $len > self
::MAX_DATA_LENGTH
) {
504 $max = self
::MAX_DATA_LENGTH
;
505 throw new OverflowException( "Comment data is too long ($len bytes, maximum is $max)" );
509 $hash = self
::hash( $comment->text
, $dbData );
510 $comment->id
= $dbw->selectField(
514 'comment_hash' => $hash,
515 'comment_text' => $comment->text
,
516 'comment_data' => $dbData,
520 if ( !$comment->id
) {
524 'comment_hash' => $hash,
525 'comment_text' => $comment->text
,
526 'comment_data' => $dbData,
530 $comment->id
= $dbw->insertId();
538 * Implementation for `self::insert()` and `self::insertWithTempTable()`
539 * @param IDatabase $dbw
540 * @param string $key A key such as "rev_comment" identifying the comment
541 * field being fetched.
542 * @param string|Message|CommentStoreComment $comment
543 * @param array|null $data
544 * @return array [ array $fields, callable $callback ]
546 private function insertInternal( IDatabase
$dbw, $key, $comment, $data ) {
550 $comment = $this->createComment( $dbw, $comment, $data );
552 if ( $this->stage
& SCHEMA_COMPAT_WRITE_OLD
) {
553 $fields[$key] = $this->lang
->truncateForDatabase( $comment->text
, 255 );
556 if ( $this->stage
& SCHEMA_COMPAT_WRITE_NEW
) {
557 $tempTableStage = isset( $this->tempTables
[$key] )
558 ?
$this->tempTables
[$key]['stage'] : MIGRATION_NEW
;
559 if ( $tempTableStage & SCHEMA_COMPAT_WRITE_OLD
) {
560 $t = $this->tempTables
[$key];
562 $commentId = $comment->id
;
563 $callback = function ( $id ) use ( $dbw, $commentId, $t, $func ) {
568 $t['field'] => $commentId,
574 if ( $tempTableStage & SCHEMA_COMPAT_WRITE_NEW
) {
575 $fields["{$key}_id"] = $comment->id
;
579 return [ $fields, $callback ];
583 * Insert a comment in preparation for a row that references it
585 * @note It's recommended to include both the call to this method and the
586 * row insert in the same transaction.
589 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
590 * @param IDatabase $dbw Database handle to insert on
591 * @param string $key A key such as "rev_comment" identifying the comment
592 * field being fetched.
593 * @param string|Message|CommentStoreComment|null $comment As for `self::createComment()`
594 * @param array|null $data As for `self::createComment()`
595 * @return array Fields for the insert or update
597 public function insert( IDatabase
$dbw, $key, $comment = null, $data = null ) {
598 // Compat for method sig change in 1.31 (introduction of $key)
599 if ( $this->key
!== null ) {
604 if ( $comment === null ) {
605 // @codeCoverageIgnoreStart
606 throw new InvalidArgumentException( '$comment can not be null' );
607 // @codeCoverageIgnoreEnd
610 $tempTableStage = isset( $this->tempTables
[$key] )
611 ?
$this->tempTables
[$key]['stage'] : MIGRATION_NEW
;
612 if ( $tempTableStage & SCHEMA_COMPAT_WRITE_OLD
) {
613 throw new InvalidArgumentException( "Must use insertWithTempTable() for $key" );
616 list( $fields ) = $this->insertInternal( $dbw, $key, $comment, $data );
621 * Insert a comment in a temporary table in preparation for a row that references it
623 * This is currently needed for "rev_comment" and "img_description". In the
624 * future that requirement will be removed.
626 * @note It's recommended to include both the call to this method and the
627 * row insert in the same transaction.
630 * @since 1.31 Method signature changed, $key parameter added (with deprecated back compat)
631 * @param IDatabase $dbw Database handle to insert on
632 * @param string $key A key such as "rev_comment" identifying the comment
633 * field being fetched.
634 * @param string|Message|CommentStoreComment|null $comment As for `self::createComment()`
635 * @param array|null $data As for `self::createComment()`
636 * @return array Two values:
637 * - array Fields for the insert or update
638 * - callable Function to call when the primary key of the row being
639 * inserted/updated is known. Pass it that primary key.
641 public function insertWithTempTable( IDatabase
$dbw, $key, $comment = null, $data = null ) {
642 // Compat for method sig change in 1.31 (introduction of $key)
643 if ( $this->key
!== null ) {
646 $key = $this->getKey();
648 if ( $comment === null ) {
649 // @codeCoverageIgnoreStart
650 throw new InvalidArgumentException( '$comment can not be null' );
651 // @codeCoverageIgnoreEnd
654 if ( !isset( $this->tempTables
[$key] ) ) {
655 throw new InvalidArgumentException( "Must use insert() for $key" );
656 } elseif ( isset( $this->tempTables
[$key]['deprecatedIn'] ) ) {
657 wfDeprecated( __METHOD__
. " for $key", $this->tempTables
[$key]['deprecatedIn'] );
660 list( $fields, $callback ) = $this->insertInternal( $dbw, $key, $comment, $data );
662 $callback = function () {
666 return [ $fields, $callback ];
670 * Encode a Message as a PHP data structure
671 * @param Message $msg
674 protected static function encodeMessage( Message
$msg ) {
675 $key = count( $msg->getKeysToTry() ) > 1 ?
$msg->getKeysToTry() : $msg->getKey();
676 $params = $msg->getParams();
677 foreach ( $params as &$param ) {
678 if ( $param instanceof Message
) {
680 'message' => self
::encodeMessage( $param )
684 array_unshift( $params, $key );
689 * Decode a message that was encoded by self::encodeMessage()
693 protected static function decodeMessage( $data ) {
694 $key = array_shift( $data );
695 foreach ( $data as &$param ) {
696 if ( is_object( $param ) ) {
697 $param = (array)$param;
699 if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) {
700 $param = self
::decodeMessage( $param['message'] );
703 return new Message( $key, $data );
707 * Hashing function for comment storage
708 * @param string $text Comment text
709 * @param string|null $data Comment data
710 * @return int 32-bit signed integer
712 public static function hash( $text, $data ) {
713 $hash = crc32( $text ) ^
crc32( (string)$data );
715 // 64-bit PHP returns an unsigned CRC, change it to signed for
716 // insertion into the database.
717 if ( $hash >= 0x80000000 ) {