Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.94% covered (danger)
21.94%
68 / 310
15.79% covered (danger)
15.79%
3 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
UncachedMenteeOverviewDataProvider
21.94% covered (danger)
21.94%
68 / 310
15.79% covered (danger)
15.79%
3 / 19
1144.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getReadConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetService
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getProfilingInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeProfilingData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIds
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getLockedMenteesIds
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 getFilteredMenteesForMentor
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 1
56
 getLastEditTimestampForUsersInternal
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 getLastEditTimestampForUsers
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 filterMenteesByLastEdit
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getFormattedDataForMentor
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getUsernames
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getRevertedEditsForUsers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestionsAskedForUsers
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getTaggedEditsForUsers
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 getRegistrationTimestampForUsers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getBlocksForUsers
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
4
 getEditCountsForUsers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace GrowthExperiments\MentorDashboard\MenteeOverview;
4
5use ChangeTags;
6use ExtensionRegistry;
7use GrowthExperiments\HomepageHooks;
8use GrowthExperiments\HomepageModules\Mentorship;
9use GrowthExperiments\Mentorship\MentorPageMentorManager;
10use GrowthExperiments\Mentorship\Store\MentorStore;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Extension\CentralAuth\CentralAuthServices;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Storage\NameTableAccessException;
15use MediaWiki\Storage\NameTableStore;
16use MediaWiki\User\ActorMigration;
17use MediaWiki\User\TempUser\TempUserConfig;
18use MediaWiki\User\UserIdentity;
19use MediaWiki\User\UserIdentityLookup;
20use Psr\Log\LoggerAwareTrait;
21use Psr\Log\NullLogger;
22use Wikimedia\Rdbms\IConnectionProvider;
23use Wikimedia\Rdbms\IExpression;
24use Wikimedia\Rdbms\IReadableDatabase;
25use Wikimedia\Timestamp\ConvertibleTimestamp;
26
27/**
28 * Provides data about interesting mentees mentored by a particular mentor
29 *
30 * WARNING: This class implements no caching, and it may be only used from CLI
31 * scripts or jobs.
32 */
33class UncachedMenteeOverviewDataProvider implements MenteeOverviewDataProvider {
34    use LoggerAwareTrait;
35
36    public const CONSTRUCTOR_OPTIONS = [
37        MainConfigNames::BlockTargetMigrationStage,
38    ];
39
40    /** @var int Number of seconds in a day */
41    private const SECONDS_DAY = 86400;
42
43    private ServiceOptions $options;
44
45    private MentorStore $mentorStore;
46
47    private NameTableStore $changeTagDefStore;
48
49    private ActorMigration $actorMigration;
50
51    private UserIdentityLookup $userIdentityLookup;
52    private TempUserConfig $tempUserConfig;
53
54    private IConnectionProvider $mainConnProvider;
55
56    /** @var array Cache used by getLastEditTimestampForUsers */
57    private $lastTimestampCache = [];
58
59    /**
60     * @var array Profiling information
61     *
62     * Stored by storeProfilingData, can be printed from
63     * updateMenteeData.php maintenance script.
64     */
65    private $profilingInfo = [];
66
67    /**
68     * @param ServiceOptions $options
69     * @param MentorStore $mentorStore
70     * @param NameTableStore $changeTagDefStore
71     * @param ActorMigration $actorMigration
72     * @param UserIdentityLookup $userIdentityLookup
73     * @param TempUserConfig $tempUserConfig
74     * @param IConnectionProvider $mainConnProvider
75     */
76    public function __construct(
77        ServiceOptions $options,
78        MentorStore $mentorStore,
79        NameTableStore $changeTagDefStore,
80        ActorMigration $actorMigration,
81        UserIdentityLookup $userIdentityLookup,
82        TempUserConfig $tempUserConfig,
83        IConnectionProvider $mainConnProvider
84    ) {
85        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
86        $this->setLogger( new NullLogger() );
87
88        $this->options = $options;
89        $this->mentorStore = $mentorStore;
90        $this->changeTagDefStore = $changeTagDefStore;
91        $this->actorMigration = $actorMigration;
92        $this->userIdentityLookup = $userIdentityLookup;
93        $this->tempUserConfig = $tempUserConfig;
94        $this->mainConnProvider = $mainConnProvider;
95    }
96
97    private function getReadConnection(): IReadableDatabase {
98        return $this->mainConnProvider->getReplicaDatabase( false, 'vslow' );
99    }
100
101    /**
102     * Do stuff that needs to happen before calculating the data
103     */
104    private function resetService(): void {
105        $this->lastTimestampCache = [];
106        $this->profilingInfo = [];
107    }
108
109    /**
110     * Get profiling information
111     *
112     * @internal Only use from updateMenteeData.php
113     * @return array
114     */
115    public function getProfilingInfo(): array {
116        return $this->profilingInfo;
117    }
118
119    /**
120     * @param string $section
121     * @param float $seconds
122     */
123    private function storeProfilingData( string $section, float $seconds ): void {
124        $this->profilingInfo[$section] = $seconds;
125    }
126
127    /**
128     * @param UserIdentity[] $users
129     * @return int[]
130     */
131    private function getIds( array $users ): array {
132        return array_map( static function ( $user ) {
133            return $user->getId();
134        }, $users );
135    }
136
137    /**
138     * Return local user IDs of all globally locked mentees
139     *
140     * If CentralAuth is not installed, this returns an empty array (as
141     * locking is not possible in that case).
142     *
143     * If CentralAuth is available, CentralAuthServices::getGlobalUserSelectQueryBuilderFactory
144     * is used to get locked mentees only. The select query builder does not impose any explicit
145     * limit on number of users that can be processed at once. UncachedMenteeOverviewDataProvider
146     * runs certain queries similar to the CentralAuth extension, which run fine with the current
147     * number of mentees (as of March 2022, the upper bound is 42,716).
148     *
149     * @param UserIdentity[] $mentees
150     * @return int[]
151     */
152    private function getLockedMenteesIds( array $mentees ): array {
153        if (
154            !ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ||
155            $mentees === []
156        ) {
157            return [];
158        }
159
160        if (
161            !method_exists( CentralAuthServices::class, 'getGlobalUserSelectQueryBuilderFactory' )
162        ) {
163            $this->logger->error(
164                'Old version of CentralAuth found, CentralAuthServices::getGlobalUserSelectQueryBuilderFactory' .
165                ' was not found'
166            );
167            return [];
168        }
169
170        $userIdentities = CentralAuthServices::getGlobalUserSelectQueryBuilderFactory()
171            ->newGlobalUserSelectQueryBuilder()
172            ->whereUserNames( array_map( static function ( UserIdentity $user ) {
173                return $user->getName();
174            }, $mentees ) )
175            ->whereLocked( true )
176            ->fetchLocalUserIdentitites();
177
178        return array_map( static function ( UserIdentity $user ) {
179            return $user->getId();
180        }, iterator_to_array( $userIdentities ) );
181    }
182
183    /**
184     * Filter mentees according to business rules
185     *
186     * Only mentees that meet all of the following conditions
187     * should be considered:
188     *  * user is not a bot
189     *  * user is not a temporary account (safety check should temp users end up as mentees)
190     *  * user is not indefinitely blocked
191     *  * user is not globally locked (via CentralAuth's implementation)
192     *  * user registered less than 2 weeks ago OR made at least one edit in the last 6 months
193     *
194     * @param UserIdentity $mentor
195     * @return int[] User IDs of the mentees
196     */
197    private function getFilteredMenteesForMentor( UserIdentity $mentor ): array {
198        $startTime = microtime( true );
199
200        $mentees = $this->mentorStore->getMenteesByMentor( $mentor, MentorStore::ROLE_PRIMARY );
201        $menteeIds = array_diff(
202            $this->getIds( $mentees ),
203            $this->getLockedMenteesIds( $mentees )
204        );
205
206        if ( $menteeIds === [] ) {
207            return [];
208        }
209
210        $dbr = $this->getReadConnection();
211
212        $queryBuilder = $dbr->newSelectQueryBuilder()
213            ->select( [
214                'user_id',
215                'has_edits' => 'user_editcount > 0'
216            ] )
217            ->from( 'user' )
218            ->where( [
219                // filter to mentees only
220                'user_id' => $menteeIds,
221
222                // ensure mentees have homepage enabled
223                'user_id IN (' . $dbr->newSelectQueryBuilder()
224                    ->select( 'up_user' )
225                    ->from( 'user_properties' )
226                    ->where( [
227                        'up_property' => HomepageHooks::HOMEPAGE_PREF_ENABLE,
228                        'up_value' => '1',
229                    ] )
230                    ->getSQL() .
231                ')',
232
233                // ensure mentees do not have mentorship disabled
234                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
235                    ->select( 'up_user' )
236                    ->from( 'user_properties' )
237                    ->where( [
238                        'up_property' => MentorPageMentorManager::MENTORSHIP_ENABLED_PREF,
239                        // sanity check, should never match (1 is the default value)
240                        $dbr->expr( 'up_value', '!=', '1' ),
241                    ] )
242                    ->getSQL() .
243                ')',
244
245                // user is not a bot,
246                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
247                    ->select( 'ug_user' )
248                    ->from( 'user_groups' )
249                    ->where( [ 'ug_group' => 'bot' ] )
250                    ->getSQL() .
251                ')',
252
253                // only users who either made an edit or registered less than 2 weeks ago
254                $dbr->expr( 'user_editcount', '>', 0 )
255                    ->or( 'user_registration', '>', $dbr->timestamp(
256                        (int)wfTimestamp( TS_UNIX ) - 2 * 7 * self::SECONDS_DAY
257                    ) ),
258            ] )
259            ->caller( __METHOD__ );
260
261        // Exclude users which are indefinitely blocked
262        if ( $this->options->get( MainConfigNames::BlockTargetMigrationStage ) & SCHEMA_COMPAT_READ_OLD ) {
263            $queryBuilder->andWhere(
264                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
265                    ->select( 'ipb_user' )
266                    ->from( 'ipblocks' )
267                    ->where( [
268                        'ipb_expiry' => $dbr->getInfinity(),
269                        // not an IP block
270                        $dbr->expr( 'ipb_user', '!=', 0 ),
271                    ] )
272                    ->getSQL() .
273                ')'
274            );
275        } else {
276            $queryBuilder->andWhere(
277                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
278                    ->select( 'bt_user' )
279                    ->from( 'block' )
280                    ->join( 'block_target', null, [
281                        'bt_id=bl_target'
282                    ] )
283                    ->where( [
284                        'bl_expiry' => $dbr->getInfinity(),
285                        // not an IP block
286                        $dbr->expr( 'bt_user', '!=', null )
287                    ] )
288                    ->getSQL() .
289                ')'
290            );
291        }
292
293        // exclude temporary accounts, if enabled (T341389)
294        if ( $this->tempUserConfig->isEnabled() ) {
295            foreach ( $this->tempUserConfig->getMatchPatterns() as $pattern ) {
296                $queryBuilder->andWhere(
297                    $dbr->expr( 'user_name', IExpression::NOT_LIKE, $pattern->toLikeValue( $dbr ) )
298                );
299            }
300        }
301
302        $res = $queryBuilder->fetchResultSet();
303
304        $editingUsers = [];
305        $notEditingUsers = [];
306        foreach ( $res as $row ) {
307            if ( $row->has_edits ) {
308                $editingUsers[] = (int)$row->user_id;
309            } else {
310                $notEditingUsers[] = (int)$row->user_id;
311            }
312        }
313
314        $this->storeProfilingData( 'filtermentees', microtime( true ) - $startTime );
315
316        return array_merge(
317            $notEditingUsers,
318            $this->filterMenteesByLastEdit( $editingUsers )
319        );
320    }
321
322    /**
323     * @param int[] $userIds
324     * @return array
325     */
326    private function getLastEditTimestampForUsersInternal( array $userIds ): array {
327        $startTime = microtime( true );
328
329        $rows = $this->getReadConnection()->newSelectQueryBuilder()
330            ->select( [
331                'actor_user',
332                'last_edit' => 'MAX(rev_timestamp)'
333            ] )
334            ->from( 'revision' )
335            ->join( 'actor', null, 'rev_actor = actor_id' )
336            ->where( [
337                'actor_user' => $userIds,
338            ] )
339            ->caller( __METHOD__ )
340            ->groupBy( 'actor_user' )
341            ->fetchResultSet();
342        $res = [];
343        foreach ( $rows as $row ) {
344            $res[$row->actor_user] = $row->last_edit;
345        }
346
347        $this->storeProfilingData(
348            'edittimestampinternal',
349            microtime( true ) - $startTime
350        );
351        return $res;
352    }
353
354    /**
355     * @param int[] $userIds
356     * @return array
357     */
358    private function getLastEditTimestampForUsers( array $userIds ): array {
359        if ( $userIds === [] ) {
360            return [];
361        }
362
363        $data = array_intersect_key( $this->lastTimestampCache, array_fill_keys( $userIds, true ) );
364        $notInCache = array_diff( $userIds, array_keys( $this->lastTimestampCache ) );
365        if ( $notInCache ) {
366            $new = $this->getLastEditTimestampForUsersInternal( $notInCache );
367            $data += $new;
368            $this->lastTimestampCache += $new;
369        }
370        return $data;
371    }
372
373    /**
374     * Filter provided user IDs to IDs of users who edited up to 6 months ago
375     *
376     * @param array $allUserIds
377     * @return int[]
378     */
379    private function filterMenteesByLastEdit( array $allUserIds ): array {
380        if ( $allUserIds === [] ) {
381            return [];
382        }
383
384        $allLastEdits = $this->getLastEditTimestampForUsers( $allUserIds );
385        $userIds = [];
386        foreach ( $allLastEdits as $userId => $lastEdit ) {
387            $secondsSinceLastEdit = (int)wfTimestamp( TS_UNIX ) -
388                (int)ConvertibleTimestamp::convert(
389                    TS_UNIX,
390                    $lastEdit
391                );
392            if ( $secondsSinceLastEdit <= self::SECONDS_DAY * 6 * 30 ) {
393                $userIds[] = $userId;
394            }
395        }
396        return $userIds;
397    }
398
399    /**
400     * Calculates data for a given mentor's mentees
401     *
402     * @param UserIdentity $mentor
403     * @return array
404     */
405    public function getFormattedDataForMentor( UserIdentity $mentor ): array {
406        $this->resetService();
407
408        $userIds = $this->getFilteredMenteesForMentor( $mentor );
409        if ( $userIds === [] ) {
410            return [];
411        }
412
413        $mainData = [
414            'username' => $this->getUsernames( $userIds ),
415            'reverted' => $this->getRevertedEditsForUsers( $userIds ),
416            'questions' => $this->getQuestionsAskedForUsers( $userIds ),
417            'editcount' => $this->getEditCountsForUsers( $userIds ),
418            'registration' => $this->getRegistrationTimestampForUsers( $userIds ),
419            'last_edit' => $this->getLastEditTimestampForUsers( $userIds ),
420            'blocks' => $this->getBlocksForUsers( $userIds ),
421        ];
422
423        $res = [];
424        foreach ( $mainData as $key => $data ) {
425            foreach ( $data as $userId => $value ) {
426                $res[$userId][$key] = $value;
427            }
428        }
429        foreach ( $res as $userId => $userData ) {
430            $res[$userId]['last_active'] = $userData['last_edit'] ?? $userData['registration'];
431        }
432        return $res;
433    }
434
435    /**
436     * @param int[] $userIds
437     * @return array
438     */
439    private function getUsernames( array $userIds ): array {
440        $startTime = microtime( true );
441
442        $rows = $this->getReadConnection()->newSelectQueryBuilder()
443            ->select( [ 'user_id', 'user_name' ] )
444            ->from( 'user' )
445            ->where( [ 'user_id' => $userIds ] )
446            ->caller( __METHOD__ )
447            ->fetchResultSet();
448        $res = [];
449        foreach ( $rows as $row ) {
450            $res[$row->user_id] = $row->user_name;
451        }
452
453        $this->storeProfilingData( 'usernames', microtime( true ) - $startTime );
454
455        return $res;
456    }
457
458    /**
459     * @param int[] $userIds
460     * @return array
461     */
462    private function getRevertedEditsForUsers( array $userIds ): array {
463        $startTime = microtime( true );
464        $res = $this->getTaggedEditsForUsers(
465            [ ChangeTags::TAG_REVERTED ],
466            $userIds
467        );
468        $this->storeProfilingData( 'reverted', microtime( true ) - $startTime );
469        return $res;
470    }
471
472    /**
473     * @param int[] $userIds
474     * @return array
475     */
476    private function getQuestionsAskedForUsers( array $userIds ): array {
477        $startTime = microtime( true );
478        $res = $this->getTaggedEditsForUsers(
479            [
480                Mentorship::MENTORSHIP_HELPPANEL_QUESTION_TAG,
481                Mentorship::MENTORSHIP_MODULE_QUESTION_TAG
482            ],
483            $userIds
484        );
485        $this->storeProfilingData( 'questions', microtime( true ) - $startTime );
486        return $res;
487    }
488
489    /**
490     * @param string[] $tags
491     * @param int[] $userIds
492     * @return int[]
493     */
494    private function getTaggedEditsForUsers( array $tags, array $userIds ) {
495        $tagIds = [];
496        foreach ( $tags as $tag ) {
497            try {
498                $tagIds[] = $this->changeTagDefStore->getId( $tag );
499            } catch ( NameTableAccessException $e ) {
500                // Skip non-existing tags gracefully
501            }
502        }
503        if ( $tagIds === [] ) {
504            return array_fill_keys( $userIds, 0 );
505        }
506
507        $dbr = $this->getReadConnection();
508        $queryInfo = $this->actorMigration->getJoin( 'rev_user' );
509        $taggedEditsSubquery = $dbr->newSelectQueryBuilder()
510            ->select( [
511                'rev_user' => $queryInfo['fields']['rev_user'],
512                'ct_rev_id'
513            ] )
514            ->from( 'change_tag' )
515            ->join( 'revision', null, 'rev_id=ct_rev_id' )
516            ->tables( $queryInfo['tables'] )
517            ->where( [
518                'actor_user' => $userIds,
519                'ct_tag_id' => $tagIds
520            ] )
521            ->joinConds( $queryInfo['joins'] )
522            ->caller( __METHOD__ );
523        $rows = $dbr->newSelectQueryBuilder()
524            ->select( [ 'user_id', 'tagged' => 'COUNT(ct_rev_id)' ] )
525            ->from( 'user' )
526            ->leftJoin( $taggedEditsSubquery, 'tagged_edits', 'rev_user=user_id' )
527            ->where( [ 'user_id' => $userIds ] )
528            ->caller( __METHOD__ )
529            ->groupBy( 'user_id' )
530            ->fetchResultSet();
531
532        $res = [];
533        foreach ( $rows as $row ) {
534            $res[$row->user_id] = (int)$row->tagged;
535        }
536        return $res;
537    }
538
539    /**
540     * @param int[] $userIds
541     * @return array
542     */
543    private function getRegistrationTimestampForUsers( array $userIds ): array {
544        $startTime = microtime( true );
545        $rows = $this->getReadConnection()->newSelectQueryBuilder()
546            ->select( [ 'user_id', 'user_registration' ] )
547            ->from( 'user' )
548            ->where( [ 'user_id' => $userIds ] )
549            ->caller( __METHOD__ )
550            ->fetchResultSet();
551        $res = [];
552        foreach ( $rows as $row ) {
553            $res[$row->user_id] = $row->user_registration;
554        }
555        $this->storeProfilingData( 'registration', microtime( true ) - $startTime );
556        return $res;
557    }
558
559    /**
560     * Get number of blocks placed against the mentees
561     *
562     * @param int[] $userIds
563     * @return int[]
564     */
565    private function getBlocksForUsers( array $userIds ): array {
566        if ( $userIds === [] ) {
567            return [];
568        }
569
570        $startTime = microtime( true );
571
572        // fetch usernames (assoc. array; username => user ID)
573        // NOTE: username has underscores, not spaces
574        $users = [];
575        $userIdentities = iterator_to_array( $this->userIdentityLookup->newSelectQueryBuilder()
576            ->whereUserIds( $userIds )
577            ->fetchUserIdentities() );
578        array_walk(
579            $userIdentities,
580            static function ( UserIdentity $value, $key ) use ( &$users ) {
581                $users[str_replace( ' ', '_', $value->getName() )] = $value->getId();
582            }
583        );
584
585        $rows = $this->getReadConnection()->newSelectQueryBuilder()
586            ->select( [ 'log_title', 'blocks' => 'COUNT(log_id)' ] )
587            ->from( 'logging' )
588            ->where( [
589                'log_type' => 'block',
590                'log_action' => 'block',
591                'log_namespace' => NS_USER,
592                'log_title' => array_keys( $users )
593            ] )
594            ->groupBy( 'log_title' )
595            ->caller( __METHOD__ )
596            ->fetchResultSet();
597
598        $res = [];
599        foreach ( $rows as $row ) {
600            $res[$users[$row->log_title]] = (int)$row->blocks;
601        }
602
603        // fill missing IDs with zeros
604        $missingIds = array_diff( $userIds, array_keys( $res ) );
605        foreach ( $missingIds as $id ) {
606            $res[$id] = 0;
607        }
608
609        $this->storeProfilingData( 'blocks', microtime( true ) - $startTime );
610
611        return $res;
612    }
613
614    /**
615     * @param int[] $userIds
616     * @return int[]
617     */
618    private function getEditCountsForUsers( array $userIds ): array {
619        $startTime = microtime( true );
620
621        $rows = $this->getReadConnection()->newSelectQueryBuilder()
622            ->select( [ 'user_id', 'user_editcount' ] )
623            ->from( 'user' )
624            ->where( [ 'user_id' => $userIds ] )
625            ->caller( __METHOD__ )
626            ->fetchResultSet();
627        $res = [];
628        foreach ( $rows as $row ) {
629            $res[$row->user_id] = (int)$row->user_editcount;
630        }
631
632        $this->storeProfilingData( 'editcount', microtime( true ) - $startTime );
633
634        return $res;
635    }
636}