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