Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.07% covered (success)
94.07%
111 / 118
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActorMigrationBase
94.87% covered (success)
94.87%
111 / 117
53.85% covered (warning)
53.85%
7 / 13
45.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 newMigrationForImport
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldInfo
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getInstanceName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkDeprecation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isAnon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isNotAnon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getFieldNames
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getJoin
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 getInsertValues
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getWhere
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
14
 setForImport
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getActorNormalization
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Methods to help with the actor table migration
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
23namespace MediaWiki\User;
24
25use InvalidArgumentException;
26use LogicException;
27use ReflectionClass;
28use Wikimedia\IPUtils;
29use Wikimedia\Rdbms\IDatabase;
30use Wikimedia\Rdbms\IReadableDatabase;
31
32/**
33 * This abstract base class helps migrate core and extension code to use the
34 * actor table.
35 *
36 * @stable to extend
37 * @since 1.37
38 */
39class ActorMigrationBase {
40    /** @var array[] Cache for `self::getJoin()` */
41    private $joinCache = [];
42
43    /** @var int One of the SCHEMA_COMPAT_READ_* values */
44    private $readStage;
45
46    /** @var int A combination of the SCHEMA_COMPAT_WRITE_* flags */
47    private $writeStage;
48
49    protected ActorStoreFactory $actorStoreFactory;
50
51    /** @var array */
52    private $fieldInfos;
53
54    private bool $allowUnknown;
55
56    private bool $forImport = false;
57
58    /**
59     * @param array $fieldInfos An array of associative arrays, giving configuration
60     *   information about fields which are being migrated. Subkeys are:
61     *    - removedVersion: The version in which the field was removed
62     *    - deprecatedVersion: The version in which the field was deprecated
63     *    - component: The component for removedVersion and deprecatedVersion.
64     *      Default: MediaWiki.
65     *    - textField: Override the old text field name. Default {$key}_text.
66     *    - actorField: Override the actor field name. Default {$key}_actor.
67     *   All subkeys are optional.
68     *
69     * @stable to override
70     * @stable to call
71     *
72     * @param int $stage The migration stage. This is a combination of
73     *   SCHEMA_COMPAT_* flags:
74     *     - SCHEMA_COMPAT_READ_OLD, SCHEMA_COMPAT_WRITE_OLD: Use the old schema,
75     *       with *_user and *_user_text fields.
76     *     - SCHEMA_COMPAT_READ_NEW, SCHEMA_COMPAT_WRITE_NEW: Use the new
77     *       schema. All relevant tables join directly to the actor table.
78     *
79     * @param ActorStoreFactory $actorStoreFactory
80     * @param array $options Array of other options. May contain:
81     *   - allowUnknown: Allow fields not present in $fieldInfos. True by default.
82     */
83    public function __construct(
84        $fieldInfos,
85        $stage,
86        ActorStoreFactory $actorStoreFactory,
87        $options = []
88    ) {
89        $this->fieldInfos = $fieldInfos;
90        $this->allowUnknown = $options['allowUnknown'] ?? true;
91
92        $writeStage = $stage & SCHEMA_COMPAT_WRITE_MASK;
93        $readStage = $stage & SCHEMA_COMPAT_READ_MASK;
94        if ( $writeStage === 0 ) {
95            throw new InvalidArgumentException( '$stage must include a write mode' );
96        }
97        if ( $readStage === 0 ) {
98            throw new InvalidArgumentException( '$stage must include a read mode' );
99        }
100        if ( !in_array( $readStage, [ SCHEMA_COMPAT_READ_OLD, SCHEMA_COMPAT_READ_NEW ] ) ) {
101            throw new InvalidArgumentException( 'Cannot read multiple schemas' );
102        }
103        if ( $readStage === SCHEMA_COMPAT_READ_OLD && !( $writeStage & SCHEMA_COMPAT_WRITE_OLD ) ) {
104            throw new InvalidArgumentException( 'Cannot read the old schema without also writing it' );
105        }
106        if ( $readStage === SCHEMA_COMPAT_READ_NEW && !( $writeStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
107            throw new InvalidArgumentException( 'Cannot read the new schema without also writing it' );
108        }
109        $this->readStage = $readStage;
110        $this->writeStage = $writeStage;
111
112        $this->actorStoreFactory = $actorStoreFactory;
113    }
114
115    /**
116     * Get an instance that allows IP actor creation
117     * @return self
118     */
119    public static function newMigrationForImport() {
120        throw new LogicException( __METHOD__ . " must be overridden" );
121    }
122
123    /**
124     * Get config information about a field.
125     *
126     * @stable to override
127     *
128     * @param string $key
129     * @return array
130     */
131    protected function getFieldInfo( $key ) {
132        if ( isset( $this->fieldInfos[$key] ) ) {
133            return $this->fieldInfos[$key];
134        } elseif ( $this->allowUnknown ) {
135            return [];
136        } else {
137            throw new InvalidArgumentException( $this->getInstanceName() . ": unknown key $key" );
138        }
139    }
140
141    /**
142     * Get a name for this instance to use in error messages
143     *
144     * @stable to override
145     *
146     * @return string
147     * @throws \ReflectionException
148     */
149    protected function getInstanceName() {
150        if ( ( new ReflectionClass( $this ) )->isAnonymous() ) {
151            // Mostly for PHPUnit
152            return self::class;
153        } else {
154            return static::class;
155        }
156    }
157
158    /**
159     * Issue deprecation warning/error as appropriate.
160     *
161     * @internal
162     *
163     * @param string $key
164     */
165    protected function checkDeprecation( $key ) {
166        $fieldInfo = $this->getFieldInfo( $key );
167        if ( isset( $fieldInfo['removedVersion'] ) ) {
168            $removedVersion = $fieldInfo['removedVersion'];
169            $component = $fieldInfo['component'] ?? 'MediaWiki';
170            throw new InvalidArgumentException(
171                "Use of {$this->getInstanceName()} for '$key' was removed in $component $removedVersion"
172            );
173        }
174        if ( isset( $fieldInfo['deprecatedVersion'] ) ) {
175            $deprecatedVersion = $fieldInfo['deprecatedVersion'];
176            $component = $fieldInfo['component'] ?? 'MediaWiki';
177            wfDeprecated( "{$this->getInstanceName()} for '$key'", $deprecatedVersion, $component, 3 );
178        }
179    }
180
181    /**
182     * Return an SQL condition to test if a user field is anonymous
183     * @param string $field Field name or SQL fragment
184     * @return string
185     */
186    public function isAnon( $field ) {
187        return ( $this->readStage & SCHEMA_COMPAT_READ_NEW ) ? "$field IS NULL" : "$field = 0";
188    }
189
190    /**
191     * Return an SQL condition to test if a user field is non-anonymous
192     * @param string $field Field name or SQL fragment
193     * @return string
194     */
195    public function isNotAnon( $field ) {
196        return ( $this->readStage & SCHEMA_COMPAT_READ_NEW ) ? "$field IS NOT NULL" : "$field != 0";
197    }
198
199    /**
200     * @param string $key A key such as "rev_user" identifying the actor
201     *  field being fetched.
202     * @return string[] [ $text, $actor ]
203     */
204    private function getFieldNames( $key ) {
205        $fieldInfo = $this->getFieldInfo( $key );
206        $textField = $fieldInfo['textField'] ?? $key . '_text';
207        $actorField = $fieldInfo['actorField'] ?? substr( $key, 0, -5 ) . '_actor';
208        return [ $textField, $actorField ];
209    }
210
211    /**
212     * Get SELECT fields and joins for the actor key
213     *
214     * @param string $key A key such as "rev_user" identifying the actor
215     *  field being fetched.
216     * @return array[] With three keys:
217     *   - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
218     *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
219     *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
220     *  All tables, fields, and joins are aliased, so `+` is safe to use.
221     * @phan-return array{tables:string[],fields:string[],joins:array}
222     */
223    public function getJoin( $key ) {
224        $this->checkDeprecation( $key );
225
226        if ( !isset( $this->joinCache[$key] ) ) {
227            $tables = [];
228            $fields = [];
229            $joins = [];
230
231            [ $text, $actor ] = $this->getFieldNames( $key );
232
233            if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
234                $fields[$key] = $key;
235                $fields[$text] = $text;
236                $fields[$actor] = 'NULL';
237            } else /* SCHEMA_COMPAT_READ_NEW */ {
238                $alias = "actor_$key";
239                $tables[$alias] = 'actor';
240                $joins[$alias] = [ 'JOIN', "{$alias}.actor_id = {$actor}" ];
241
242                $fields[$key] = "{$alias}.actor_user";
243                $fields[$text] = "{$alias}.actor_name";
244                $fields[$actor] = $actor;
245            }
246
247            $this->joinCache[$key] = [
248                'tables' => $tables,
249                'fields' => $fields,
250                'joins' => $joins,
251            ];
252        }
253
254        return $this->joinCache[$key];
255    }
256
257    /**
258     * Get UPDATE fields for the actor
259     *
260     * @param IDatabase $dbw Database to use for creating an actor ID, if necessary
261     * @param string $key A key such as "rev_user" identifying the actor
262     *  field being fetched.
263     * @param UserIdentity $user User to set in the update
264     * @return array to merge into `$values` to `IDatabase->update()` or `$a` to `IDatabase->insert()`
265     */
266    public function getInsertValues( IDatabase $dbw, $key, UserIdentity $user ) {
267        $this->checkDeprecation( $key );
268
269        [ $text, $actor ] = $this->getFieldNames( $key );
270        $ret = [];
271        if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
272            $ret[$key] = $user->getId();
273            $ret[$text] = $user->getName();
274        }
275        if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
276            $ret[$actor] = $this->getActorNormalization( $dbw->getDomainID() )
277                ->acquireActorId( $user, $dbw );
278        }
279        return $ret;
280    }
281
282    /**
283     * Get WHERE condition for the actor
284     *
285     * @param IReadableDatabase $db Database to use for quoting and list-making
286     * @param string $key A key such as "rev_user" identifying the actor
287     *  field being fetched.
288     * @param UserIdentity|UserIdentity[]|null|false $users Users to test for.
289     *  Passing null, false, or the empty array will return 'conds' that never match,
290     *  and an empty array for 'orconds'.
291     * @param bool $useId If false, don't try to query by the user ID.
292     *  Intended for use with rc_user since it has an index on
293     *  (rc_user_text,rc_timestamp) but not (rc_user,rc_timestamp).
294     * @return array With four keys:
295     *   - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
296     *   - conds: (string) to include in the `$cond` to `IDatabase->select()` or `SelectQueryBuilder::conds`
297     *   - orconds: (string[]) array of alternatives in case a union of multiple
298     *     queries would be more efficient than a query with OR. May have keys
299     *     'actor', 'userid', 'username'.
300     *     Since 1.32, this is guaranteed to contain just one alternative if
301     *     $users contains a single user.
302     *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
303     *  All tables and joins are aliased, so `+` is safe to use.
304     * @phan-return array{tables:string[],conds:string,orconds:string[],joins:array}
305     */
306    public function getWhere( IReadableDatabase $db, $key, $users, $useId = true ) {
307        $this->checkDeprecation( $key );
308
309        $tables = [];
310        $conds = [];
311        $joins = [];
312
313        if ( $users instanceof UserIdentity ) {
314            $users = [ $users ];
315        } elseif ( $users === null || $users === false ) {
316            // DWIM
317            $users = [];
318        } elseif ( !is_array( $users ) ) {
319            $what = get_debug_type( $users );
320            throw new InvalidArgumentException(
321                __METHOD__ . ": Value for \$users must be a UserIdentity or array, got $what"
322            );
323        }
324
325        // Get information about all the passed users
326        $ids = [];
327        $names = [];
328        $actors = [];
329        foreach ( $users as $user ) {
330            if ( $useId && $user->isRegistered() ) {
331                $ids[] = $user->getId();
332            } else {
333                // make sure to use normalized form of IP for anonymous users
334                $names[] = IPUtils::sanitizeIP( $user->getName() );
335            }
336            $actorId = $this->getActorNormalization( $db->getDomainID() )
337                ->findActorId( $user, $db );
338
339            if ( $actorId ) {
340                $actors[] = $actorId;
341            }
342        }
343
344        [ $text, $actor ] = $this->getFieldNames( $key );
345
346        // Combine data into conditions to be ORed together
347        if ( $this->readStage === SCHEMA_COMPAT_READ_NEW ) {
348            if ( $actors ) {
349                $conds['newactor'] = $db->makeList( [ $actor => $actors ], IDatabase::LIST_AND );
350            }
351        } else {
352            if ( $ids ) {
353                $conds['userid'] = $db->makeList( [ $key => $ids ], IDatabase::LIST_AND );
354            }
355            if ( $names ) {
356                $conds['username'] = $db->makeList( [ $text => $names ], IDatabase::LIST_AND );
357            }
358        }
359
360        return [
361            'tables' => $tables,
362            'conds' => $conds ? $db->makeList( array_values( $conds ), IDatabase::LIST_OR ) : '1=0',
363            'orconds' => $conds,
364            'joins' => $joins,
365        ];
366    }
367
368    /**
369     * @internal For use immediately after construction only
370     * @param bool $forImport
371     */
372    public function setForImport( bool $forImport ): void {
373        $this->forImport = $forImport;
374    }
375
376    /**
377     * @param string $domainId
378     * @return ActorNormalization
379     */
380    protected function getActorNormalization( $domainId ): ActorNormalization {
381        if ( $this->forImport ) {
382            return $this->actorStoreFactory->getActorNormalizationForImport( $domainId );
383        } else {
384            return $this->actorStoreFactory->getActorNormalization( $domainId );
385        }
386    }
387}
388
389/** @deprecated class alias since 1.40 */
390class_alias( ActorMigrationBase::class, 'ActorMigrationBase' );