MediaWiki  master
CommentStore.php
Go to the documentation of this file.
1 <?php
23 
42 class CommentStore {
43 
48  public const COMMENT_CHARACTER_LIMIT = 500;
49 
55  public const MAX_DATA_LENGTH = 65535;
56 
67  protected const TEMP_TABLES = [
68  'rev_comment' => [
69  'table' => 'revision_comment_temp',
70  'pk' => 'revcomment_rev',
71  'field' => 'revcomment_comment_id',
72  'joinPK' => 'rev_id',
73  'stage' => MIGRATION_OLD,
74  'deprecatedIn' => null,
75  ],
76  'img_description' => [
77  'stage' => MIGRATION_NEW,
78  'deprecatedIn' => '1.32',
79  ],
80  ];
81 
88  private $stage;
89 
91  private $joinCache = [];
92 
94  private $lang;
95 
103  public function __construct( Language $lang, $stage ) {
104  if ( ( $stage & SCHEMA_COMPAT_WRITE_BOTH ) === 0 ) {
105  throw new InvalidArgumentException( '$stage must include a write mode' );
106  }
107  if ( ( $stage & SCHEMA_COMPAT_READ_BOTH ) === 0 ) {
108  throw new InvalidArgumentException( '$stage must include a read mode' );
109  }
110 
111  $this->stage = $stage;
112  $this->lang = $lang;
113  }
114 
120  public static function getStore() {
121  return MediaWikiServices::getInstance()->getCommentStore();
122  }
123 
140  public function getFields( $key ) {
141  $fields = [];
142  if ( ( $this->stage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_OLD ) {
143  $fields["{$key}_text"] = $key;
144  $fields["{$key}_data"] = 'NULL';
145  $fields["{$key}_cid"] = 'NULL';
146  } else { // READ_BOTH or READ_NEW
147  if ( $this->stage & SCHEMA_COMPAT_READ_OLD ) {
148  $fields["{$key}_old"] = $key;
149  }
150 
151  $tempTableStage = static::TEMP_TABLES[$key]['stage'] ?? MIGRATION_NEW;
152  if ( $tempTableStage & SCHEMA_COMPAT_READ_OLD ) {
153  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
154  $fields["{$key}_pk"] = static::TEMP_TABLES[$key]['joinPK'];
155  }
156  if ( $tempTableStage & SCHEMA_COMPAT_READ_NEW ) {
157  $fields["{$key}_id"] = "{$key}_id";
158  }
159  }
160  return $fields;
161  }
162 
180  public function getJoin( $key ) {
181  if ( !array_key_exists( $key, $this->joinCache ) ) {
182  $tables = [];
183  $fields = [];
184  $joins = [];
185 
186  if ( ( $this->stage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_OLD ) {
187  $fields["{$key}_text"] = $key;
188  $fields["{$key}_data"] = 'NULL';
189  $fields["{$key}_cid"] = 'NULL';
190  } else { // READ_BOTH or READ_NEW
191  $join = ( $this->stage & SCHEMA_COMPAT_READ_OLD ) ? 'LEFT JOIN' : 'JOIN';
192 
193  $tempTableStage = static::TEMP_TABLES[$key]['stage'] ?? MIGRATION_NEW;
194  if ( $tempTableStage & SCHEMA_COMPAT_READ_OLD ) {
195  $t = static::TEMP_TABLES[$key];
196  $alias = "temp_$key";
197  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
198  $tables[$alias] = $t['table'];
199  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
200  $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
201  if ( ( $tempTableStage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_OLD ) {
202  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
203  $joinField = "{$alias}.{$t['field']}";
204  } else {
205  // Nothing hits this code path for now, but will in the future when we set
206  // static::TEMP_TABLES['rev_comment']['stage'] to MIGRATION_WRITE_NEW while
207  // merging revision_comment_temp into revision.
208  // @codeCoverageIgnoreStart
209  $joins[$alias][0] = 'LEFT JOIN';
210  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
211  $joinField = "(CASE WHEN {$key}_id != 0 THEN {$key}_id ELSE {$alias}.{$t['field']} END)";
212  throw new LogicException( 'Nothing should reach this code path at this time' );
213  // @codeCoverageIgnoreEnd
214  }
215  } else {
216  $joinField = "{$key}_id";
217  }
218 
219  $alias = "comment_$key";
220  $tables[$alias] = 'comment';
221  $joins[$alias] = [ $join, "{$alias}.comment_id = {$joinField}" ];
222 
223  if ( ( $this->stage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_NEW ) {
224  $fields["{$key}_text"] = "{$alias}.comment_text";
225  } else {
226  $fields["{$key}_text"] = "COALESCE( {$alias}.comment_text, $key )";
227  }
228  $fields["{$key}_data"] = "{$alias}.comment_data";
229  $fields["{$key}_cid"] = "{$alias}.comment_id";
230  }
231 
232  $this->joinCache[$key] = [
233  'tables' => $tables,
234  'fields' => $fields,
235  'joins' => $joins,
236  ];
237  }
238 
239  return $this->joinCache[$key];
240  }
241 
254  private function getCommentInternal( ?IDatabase $db, $key, $row, $fallback = false ) {
255  $row = (array)$row;
256  if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) {
257  $cid = $row["{$key}_cid"] ?? null;
258  $text = $row["{$key}_text"];
259  $data = $row["{$key}_data"];
260  } elseif ( ( $this->stage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_OLD ) {
261  $cid = null;
262  if ( $fallback && isset( $row[$key] ) ) {
263  wfLogWarning( "Using deprecated fallback handling for comment $key" );
264  $text = $row[$key];
265  } else {
266  wfLogWarning(
267  "Missing {$key}_text and {$key}_data fields in row with MIGRATION_OLD / READ_OLD"
268  );
269  $text = '';
270  }
271  $data = null;
272  } else {
273  $tempTableStage = static::TEMP_TABLES[$key]['stage'] ?? MIGRATION_NEW;
274  $row2 = null;
275  if ( ( $tempTableStage & SCHEMA_COMPAT_READ_NEW ) && array_key_exists( "{$key}_id", $row ) ) {
276  if ( !$db ) {
277  throw new InvalidArgumentException(
278  "\$row does not contain fields needed for comment $key and getComment(), but "
279  . "does have fields for getCommentLegacy()"
280  );
281  }
282  $id = $row["{$key}_id"];
283  $row2 = $db->selectRow(
284  'comment',
285  [ 'comment_id', 'comment_text', 'comment_data' ],
286  [ 'comment_id' => $id ],
287  __METHOD__
288  );
289  }
290  if ( !$row2 && ( $tempTableStage & SCHEMA_COMPAT_READ_OLD ) &&
291  array_key_exists( "{$key}_pk", $row )
292  ) {
293  if ( !$db ) {
294  throw new InvalidArgumentException(
295  "\$row does not contain fields needed for comment $key and getComment(), but "
296  . "does have fields for getCommentLegacy()"
297  );
298  }
299  $t = static::TEMP_TABLES[$key];
300  $id = $row["{$key}_pk"];
301  $row2 = $db->selectRow(
302  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
303  [ $t['table'], 'comment' ],
304  [ 'comment_id', 'comment_text', 'comment_data' ],
305  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
306  [ $t['pk'] => $id ],
307  __METHOD__,
308  [],
309  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
310  [ 'comment' => [ 'JOIN', [ "comment_id = {$t['field']}" ] ] ]
311  );
312  }
313  if ( $row2 === null && $fallback && isset( $row[$key] ) ) {
314  wfLogWarning( "Using deprecated fallback handling for comment $key" );
315  $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
316  }
317  if ( $row2 === null ) {
318  throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
319  }
320 
321  if ( $row2 ) {
322  $cid = $row2->comment_id;
323  $text = $row2->comment_text;
324  $data = $row2->comment_data;
325  } elseif ( ( $this->stage & SCHEMA_COMPAT_READ_OLD ) &&
326  array_key_exists( "{$key}_old", $row )
327  ) {
328  $cid = null;
329  $text = $row["{$key}_old"];
330  $data = null;
331  } else {
332  // @codeCoverageIgnoreStart
333  // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $id is set when $row2 is okay
334  wfLogWarning( "Missing comment row for $key, id=$id" );
335  $cid = null;
336  $text = '';
337  $data = null;
338  // @codeCoverageIgnoreEnd
339  }
340  }
341 
342  $msg = null;
343  if ( $data !== null ) {
344  $data = FormatJson::decode( $data, true );
345  if ( !is_array( $data ) ) {
346  // @codeCoverageIgnoreStart
347  wfLogWarning( "Invalid JSON object in comment: $data" );
348  $data = null;
349  // @codeCoverageIgnoreEnd
350  } else {
351  if ( isset( $data['_message'] ) ) {
352  $msg = self::decodeMessage( $data['_message'] )
353  ->setInterfaceMessageFlag( true );
354  }
355  if ( !empty( $data['_null'] ) ) {
356  $data = null;
357  } else {
358  foreach ( $data as $k => $v ) {
359  if ( substr( $k, 0, 1 ) === '_' ) {
360  unset( $data[$k] );
361  }
362  }
363  }
364  }
365  }
366 
367  return new CommentStoreComment( $cid, $text, $msg, $data );
368  }
369 
386  public function getComment( $key, $row = null, $fallback = false ) {
387  if ( $row === null ) {
388  // @codeCoverageIgnoreStart
389  throw new InvalidArgumentException( '$row must not be null' );
390  // @codeCoverageIgnoreEnd
391  }
392  return $this->getCommentInternal( null, $key, $row, $fallback );
393  }
394 
414  public function getCommentLegacy( IDatabase $db, $key, $row = null, $fallback = false ) {
415  if ( $row === null ) {
416  // @codeCoverageIgnoreStart
417  throw new InvalidArgumentException( '$row must not be null' );
418  // @codeCoverageIgnoreEnd
419  }
420  return $this->getCommentInternal( $db, $key, $row, $fallback );
421  }
422 
443  public function createComment( IDatabase $dbw, $comment, array $data = null ) {
444  $comment = CommentStoreComment::newUnsavedComment( $comment, $data );
445 
446  # Truncate comment in a Unicode-sensitive manner
447  $comment->text = $this->lang->truncateForVisual( $comment->text, self::COMMENT_CHARACTER_LIMIT );
448 
449  if ( ( $this->stage & SCHEMA_COMPAT_WRITE_NEW ) && !$comment->id ) {
450  $dbData = $comment->data;
451  if ( !$comment->message instanceof RawMessage ) {
452  if ( $dbData === null ) {
453  $dbData = [ '_null' => true ];
454  }
455  $dbData['_message'] = self::encodeMessage( $comment->message );
456  }
457  if ( $dbData !== null ) {
458  $dbData = FormatJson::encode( (object)$dbData, false, FormatJson::ALL_OK );
459  $len = strlen( $dbData );
460  if ( $len > self::MAX_DATA_LENGTH ) {
461  $max = self::MAX_DATA_LENGTH;
462  throw new OverflowException( "Comment data is too long ($len bytes, maximum is $max)" );
463  }
464  }
465 
466  $hash = self::hash( $comment->text, $dbData );
467  $commentId = $dbw->selectField(
468  'comment',
469  'comment_id',
470  [
471  'comment_hash' => $hash,
472  'comment_text' => $comment->text,
473  'comment_data' => $dbData,
474  ],
475  __METHOD__
476  );
477  if ( !$commentId ) {
478  $dbw->insert(
479  'comment',
480  [
481  'comment_hash' => $hash,
482  'comment_text' => $comment->text,
483  'comment_data' => $dbData,
484  ],
485  __METHOD__
486  );
487  $commentId = $dbw->insertId();
488  }
489  $comment->id = (int)$commentId;
490  }
491 
492  return $comment;
493  }
494 
504  private function insertInternal( IDatabase $dbw, $key, $comment, $data ) {
505  $fields = [];
506  $callback = null;
507 
508  $comment = $this->createComment( $dbw, $comment, $data );
509 
510  if ( $this->stage & SCHEMA_COMPAT_WRITE_OLD ) {
511  $fields[$key] = $this->lang->truncateForDatabase( $comment->text, 255 );
512  }
513 
514  if ( $this->stage & SCHEMA_COMPAT_WRITE_NEW ) {
515  $tempTableStage = static::TEMP_TABLES[$key]['stage'] ?? MIGRATION_NEW;
516  if ( $tempTableStage & SCHEMA_COMPAT_WRITE_OLD ) {
517  $t = static::TEMP_TABLES[$key];
518  $func = __METHOD__;
519  $commentId = $comment->id;
520  $callback = static function ( $id ) use ( $dbw, $commentId, $t, $func ) {
521  $dbw->insert(
522  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
523  $t['table'],
524  [
525  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
526  $t['pk'] => $id,
527  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Only set for stage old
528  $t['field'] => $commentId,
529  ],
530  $func
531  );
532  };
533  }
534  if ( $tempTableStage & SCHEMA_COMPAT_WRITE_NEW ) {
535  $fields["{$key}_id"] = $comment->id;
536  }
537  }
538 
539  return [ $fields, $callback ];
540  }
541 
557  public function insert( IDatabase $dbw, $key, $comment = null, $data = null ) {
558  if ( $comment === null ) {
559  // @codeCoverageIgnoreStart
560  throw new InvalidArgumentException( '$comment can not be null' );
561  // @codeCoverageIgnoreEnd
562  }
563 
564  $tempTableStage = static::TEMP_TABLES[$key]['stage'] ?? MIGRATION_NEW;
565  if ( $tempTableStage & SCHEMA_COMPAT_WRITE_OLD ) {
566  throw new InvalidArgumentException( "Must use insertWithTempTable() for $key" );
567  }
568 
569  list( $fields ) = $this->insertInternal( $dbw, $key, $comment, $data );
570  return $fields;
571  }
572 
594  public function insertWithTempTable( IDatabase $dbw, $key, $comment = null, $data = null ) {
595  if ( $comment === null ) {
596  // @codeCoverageIgnoreStart
597  throw new InvalidArgumentException( '$comment can not be null' );
598  // @codeCoverageIgnoreEnd
599  }
600 
601  if ( !isset( static::TEMP_TABLES[$key] ) ) {
602  throw new InvalidArgumentException( "Must use insert() for $key" );
603  } elseif ( isset( static::TEMP_TABLES[$key]['deprecatedIn'] ) ) {
604  wfDeprecated( __METHOD__ . " for $key", static::TEMP_TABLES[$key]['deprecatedIn'] );
605  }
606 
607  list( $fields, $callback ) = $this->insertInternal( $dbw, $key, $comment, $data );
608  if ( !$callback ) {
609  $callback = static function () {
610  // Do nothing.
611  };
612  }
613  return [ $fields, $callback ];
614  }
615 
621  private static function encodeMessage( Message $msg ) {
622  $key = count( $msg->getKeysToTry() ) > 1 ? $msg->getKeysToTry() : $msg->getKey();
623  $params = $msg->getParams();
624  foreach ( $params as &$param ) {
625  if ( $param instanceof Message ) {
626  $param = [
627  'message' => self::encodeMessage( $param )
628  ];
629  }
630  }
631  array_unshift( $params, $key );
632  return $params;
633  }
634 
640  private static function decodeMessage( $data ) {
641  $key = array_shift( $data );
642  foreach ( $data as &$param ) {
643  if ( is_object( $param ) ) {
644  $param = (array)$param;
645  }
646  if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) {
647  $param = self::decodeMessage( $param['message'] );
648  }
649  }
650  return new Message( $key, $data );
651  }
652 
659  public static function hash( $text, $data ) {
660  $hash = crc32( $text ) ^ crc32( (string)$data );
661 
662  // 64-bit PHP returns an unsigned CRC, change it to signed for
663  // insertion into the database.
664  if ( $hash >= 0x80000000 ) {
665  $hash |= -1 << 32;
666  }
667 
668  return $hash;
669  }
670 
671 }
const SCHEMA_COMPAT_WRITE_BOTH
Definition: Defines.php:273
const SCHEMA_COMPAT_READ_NEW
Definition: Defines.php:270
const SCHEMA_COMPAT_READ_BOTH
Definition: Defines.php:276
const SCHEMA_COMPAT_WRITE_OLD
Definition: Defines.php:265
const SCHEMA_COMPAT_READ_OLD
Definition: Defines.php:266
const MIGRATION_NEW
Definition: Defines.php:306
const SCHEMA_COMPAT_WRITE_NEW
Definition: Defines.php:269
const MIGRATION_OLD
Definition: Defines.php:303
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
$fallback
Definition: MessagesAb.php:10
Value object for a comment stored by CommentStore.
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
Handle database storage of comments such as edit summaries and log reasons.
getFields( $key)
Get SELECT fields for the comment key.
getComment( $key, $row=null, $fallback=false)
Extract the comment from a row.
createComment(IDatabase $dbw, $comment, array $data=null)
Create a new CommentStoreComment, inserting it into the database if necessary.
insertWithTempTable(IDatabase $dbw, $key, $comment=null, $data=null)
Insert a comment in a temporary table in preparation for a row that references it.
__construct(Language $lang, $stage)
getCommentLegacy(IDatabase $db, $key, $row=null, $fallback=false)
Extract the comment from a row, with legacy lookups.
static hash( $text, $data)
Hashing function for comment storage.
const TEMP_TABLES
Define fields that use temporary tables for transitional purposes Array keys are field names,...
insert(IDatabase $dbw, $key, $comment=null, $data=null)
Insert a comment in preparation for a row that references it.
const MAX_DATA_LENGTH
Maximum length of serialized data in bytes.
static getStore()
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
getJoin( $key)
Get SELECT fields and joins for the comment key.
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:96
static decode( $value, $assoc=false)
Decodes a JSON string.
Definition: FormatJson.php:146
const ALL_OK
Skip escaping as many characters as reasonably possible.
Definition: FormatJson.php:55
Base class for language-specific code.
Definition: Language.php:53
Service locator for MediaWiki core services.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:140
getParams()
Returns the message parameters.
Definition: Message.php:371
setInterfaceMessageFlag( $interface)
Allows manipulating the interface message flag directly.
Definition: Message.php:889
getKeysToTry()
Definition: Message.php:345
getKey()
Returns the message key.
Definition: Message.php:360
Variant of the Message class.
Definition: RawMessage.php:35
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:39
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
selectField( $table, $var, $cond='', $fname=__METHOD__, $options=[], $join_conds=[])
A SELECT wrapper which returns a single field from a single result row.
insert( $table, $rows, $fname=__METHOD__, $options=[])
Insert row(s) into a table, in the provided order.
insertId()
Get the inserted value of an auto-increment row.
return true
Definition: router.php:90
if(!isset( $args[0])) $lang