Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.28% covered (success)
98.28%
114 / 116
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentStore
99.13% covered (success)
99.13%
114 / 115
90.00% covered (success)
90.00%
9 / 10
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJoin
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 getCommentInternal
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
16
 getComment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getCommentLegacy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 createComment
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
6
 insert
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 encodeMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 decodeMessage
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 hash
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
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
21namespace MediaWiki\CommentStore;
22
23use FormatJson;
24use InvalidArgumentException;
25use Language;
26use MediaWiki\Language\RawMessage;
27use MediaWiki\Message\Message;
28use OverflowException;
29use stdClass;
30use Wikimedia\Rdbms\IDatabase;
31use 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 */
51class 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 */
409class_alias( CommentStore::class, 'CommentStore' );