Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.43% covered (warning)
71.43%
200 / 280
62.50% covered (warning)
62.50%
15 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryChangesAsRdf
71.43% covered (warning)
71.43%
200 / 280
62.50% covered (warning)
62.50%
15 / 24
143.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 initialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 execute
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 getInsertRdf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCategoriesUpdate
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 writeParentCategories
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 updateTS
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 setupChangesIterator
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getNewCatsIterator
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getMovedCatsIterator
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getDeletedCatsIterator
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getRestoredCatsIterator
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getChangedCatsIterator
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 addTimestampConditions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addIndex
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getCategoryLinksIterator
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getRdf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handleDeletes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 writeCategoryData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 handleMoves
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 handleRestores
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 handleAdds
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 handleEdits
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 handleCategorization
96.61% covered (success)
96.61%
57 / 59
0.00% covered (danger)
0.00%
0 / 1
13
1<?php
2/**
3 * @license GPL-2.0-or-later
4 */
5
6use MediaWiki\Category\CategoriesRdf;
7use MediaWiki\MainConfigNames;
8use MediaWiki\Maintenance\Maintenance;
9use MediaWiki\RecentChanges\RecentChange;
10use MediaWiki\Utils\BatchRowIterator;
11use MediaWiki\Utils\MWTimestamp;
12use Wikimedia\Purtle\RdfWriter;
13use Wikimedia\Purtle\TurtleRdfWriter;
14use Wikimedia\Rdbms\IReadableDatabase;
15use Wikimedia\Timestamp\TimestampFormat as TS;
16
17// @codeCoverageIgnoreStart
18require_once __DIR__ . '/Maintenance.php';
19// @codeCoverageIgnoreEnd
20
21/**
22 * Maintenance script to provide RDF representation of the recent changes in category tree.
23 *
24 * @ingroup Maintenance
25 * @since 1.30
26 */
27class CategoryChangesAsRdf extends Maintenance {
28    /**
29     * Insert query
30     */
31    private const SPARQL_INSERT = <<<SPARQL
32INSERT DATA {
33%s
34};
35
36SPARQL;
37
38    /**
39     * Delete query
40     */
41    private const SPARQL_DELETE = <<<SPARQLD
42DELETE {
43?category ?x ?y
44} WHERE {
45   ?category ?x ?y
46   VALUES ?category {
47     %s
48   }
49};
50
51SPARQLD;
52
53    /**
54     * @var RdfWriter
55     */
56    private $rdfWriter;
57    /**
58     * Categories RDF helper.
59     * @var CategoriesRdf
60     */
61    private $categoriesRdf;
62
63    /** @var string */
64    private $startTS;
65    /** @var string */
66    private $endTS;
67
68    /**
69     * List of processed page IDs,
70     * so we don't try to process same thing twice
71     * @var true[]
72     */
73    protected $processed = [];
74
75    public function __construct() {
76        parent::__construct();
77
78        $this->addDescription( "Generate RDF dump of category changes in a wiki." );
79
80        $this->setBatchSize( 200 );
81        $this->addOption( 'output', "Output file (default is stdout). Will be overwritten.", false,
82            true, 'o' );
83        $this->addOption( 'start', 'Starting timestamp (inclusive), in ISO or MediaWiki format.',
84            true, true, 's' );
85        $this->addOption( 'end', 'Ending timestamp (exclusive), in ISO or MediaWiki format.', true,
86            true, 'e' );
87    }
88
89    /**
90     * Initialize external service classes.
91     */
92    public function initialize() {
93        // SPARQL Update syntax is close to Turtle format, so we can use Turtle writer.
94        $this->rdfWriter = new TurtleRdfWriter();
95        $this->categoriesRdf = new CategoriesRdf( $this->rdfWriter );
96    }
97
98    public function execute() {
99        $this->initialize();
100        $startTS = new MWTimestamp( $this->getOption( "start" ) );
101
102        $endTS = new MWTimestamp( $this->getOption( "end" ) );
103        $now = new MWTimestamp();
104        $rcMaxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge );
105
106        if ( (int)$now->getTimestamp( TS::UNIX ) - (int)$startTS->getTimestamp( TS::UNIX ) > $rcMaxAge ) {
107            $this->error( "Start timestamp too old, maximum RC age is $rcMaxAge!" );
108        }
109        if ( (int)$now->getTimestamp( TS::UNIX ) - (int)$endTS->getTimestamp( TS::UNIX ) > $rcMaxAge ) {
110            $this->error( "End timestamp too old, maximum RC age is $rcMaxAge!" );
111        }
112
113        $this->startTS = $startTS->getTimestamp();
114        $this->endTS = $endTS->getTimestamp();
115
116        $outFile = $this->getOption( 'output', 'php://stdout' );
117        if ( $outFile === '-' ) {
118            $outFile = 'php://stdout';
119        }
120
121        $output = fopen( $outFile, 'wb' );
122
123        $this->categoriesRdf->setupPrefixes();
124        $this->rdfWriter->start();
125
126        $prefixes = $this->getRdf();
127        // We have to strip @ from prefix, since SPARQL UPDATE doesn't use them
128        // Also strip dot at the end.
129        $prefixes = preg_replace( [ '/^@/m', '/\s*[.]$/m' ], '', $prefixes );
130        fwrite( $output, $prefixes );
131
132        $dbr = $this->getDB( DB_REPLICA, [ 'vslow' ] );
133
134        // Deletes go first because if the page was deleted, other changes
135        // do not matter. This only gets true deletes, i.e. not pages that were restored.
136        $this->handleDeletes( $dbr, $output );
137        // Moves go before additions because if category is moved, we should not process creation
138        // as it would produce wrong data - because create row has old title
139        $this->handleMoves( $dbr, $output );
140        // We need to handle restores too since delete may have happened in previous update.
141        $this->handleRestores( $dbr, $output );
142        // Process newly added pages
143        $this->handleAdds( $dbr, $output );
144        // Process page edits
145        $this->handleEdits( $dbr, $output );
146        // Process categorization changes
147        $this->handleCategorization( $dbr, $output );
148
149        // Update timestamp
150        fwrite( $output, $this->updateTS( $this->endTS ) );
151    }
152
153    /**
154     * Get the text of SPARQL INSERT DATA clause
155     * @return string
156     */
157    private function getInsertRdf() {
158        $rdfText = $this->getRdf();
159        if ( !$rdfText ) {
160            return "";
161        }
162        return sprintf( self::SPARQL_INSERT, $rdfText );
163    }
164
165    /**
166     * Get SPARQL for updating set of categories
167     * @param IReadableDatabase $dbr
168     * @param string[] $deleteUrls List of URIs to be deleted, with <>
169     * @param string[] $pages List of categories: id => title
170     * @param string $mark Marks which operation requests the query
171     * @return string SPARQL query
172     */
173    private function getCategoriesUpdate( IReadableDatabase $dbr, $deleteUrls, $pages, $mark ) {
174        if ( !$deleteUrls ) {
175            return "";
176        }
177
178        if ( $pages ) {
179            $this->writeParentCategories( $dbr, $pages );
180        }
181
182        return "$mark\n" . sprintf( self::SPARQL_DELETE, implode( ' ', $deleteUrls ) ) .
183            $this->getInsertRdf();
184    }
185
186    /**
187     * Write parent data for a set of categories.
188     * The list has the child categories.
189     * @param IReadableDatabase $dbr
190     * @param string[] $pages List of child categories: id => title
191     */
192    private function writeParentCategories( IReadableDatabase $dbr, $pages ) {
193        foreach ( $this->getCategoryLinksIterator( $dbr, array_keys( $pages ), __METHOD__ ) as $row ) {
194            $this->categoriesRdf->writeCategoryLinkData( $pages[$row->cl_from], $row->lt_title );
195        }
196    }
197
198    /**
199     * Generate SPARQL Update code for updating dump timestamp
200     * @param string|int $timestamp Timestamp for last change
201     * @return string SPARQL Update query for timestamp.
202     */
203    public function updateTS( $timestamp ) {
204        $dumpUrl = '<' . $this->categoriesRdf->getDumpURI() . '>';
205        $ts = wfTimestamp( TS::ISO_8601, $timestamp );
206        $tsQuery = <<<SPARQL
207DELETE {
208  $dumpUrl schema:dateModified ?o .
209}
210WHERE {
211  $dumpUrl schema:dateModified ?o .
212};
213INSERT DATA {
214  $dumpUrl schema:dateModified "$ts"^^xsd:dateTime .
215}
216
217SPARQL;
218        return $tsQuery;
219    }
220
221    /**
222     * Set up standard iterator for retrieving category changes.
223     * @param IReadableDatabase $dbr
224     * @param string[] $columns List of additional fields to get
225     * @param string $fname Name of the calling function
226     * @return BatchRowIterator
227     */
228    private function setupChangesIterator(
229        IReadableDatabase $dbr,
230        array $columns,
231        string $fname
232    ) {
233        $it = new BatchRowIterator( $dbr,
234            $dbr->newSelectQueryBuilder()
235                ->from( 'recentchanges' )
236                ->leftJoin( 'page_props', null, [ 'pp_propname' => 'hiddencat', 'pp_page = rc_cur_id' ] )
237                ->leftJoin( 'category', null, [ 'cat_title = rc_title' ] )
238                ->select( array_merge( $columns, [
239                    'rc_title',
240                    'rc_cur_id',
241                    'pp_propname',
242                    'cat_pages',
243                    'cat_subcats',
244                    'cat_files'
245                ] ) )
246                ->caller( $fname ),
247            [ 'rc_timestamp' ],
248            $this->mBatchSize
249        );
250        $this->addTimestampConditions( $it, $dbr );
251        return $it;
252    }
253
254    /**
255     * Fetch newly created categories
256     * @param IReadableDatabase $dbr
257     * @param string $fname Name of the calling function
258     * @return BatchRowIterator
259     */
260    protected function getNewCatsIterator( IReadableDatabase $dbr, $fname ) {
261        $it = $this->setupChangesIterator( $dbr, [], $fname );
262        $it->sqb->conds( [
263            'rc_namespace' => NS_CATEGORY,
264            'rc_source' => RecentChange::SRC_NEW,
265        ] );
266        return $it;
267    }
268
269    /**
270     * Fetch moved categories
271     * @param IReadableDatabase $dbr
272     * @param string $fname Name of the calling function
273     * @return BatchRowIterator
274     */
275    protected function getMovedCatsIterator( IReadableDatabase $dbr, $fname ) {
276        $it = $this->setupChangesIterator(
277            $dbr,
278            [ 'page_title', 'page_namespace' ],
279            $fname
280        );
281        $it->sqb->conds( [
282            'rc_namespace' => NS_CATEGORY,
283            'rc_source' => RecentChange::SRC_LOG,
284            'rc_log_type' => 'move',
285        ] );
286        $it->sqb->join( 'page', null, 'rc_cur_id = page_id' );
287        $this->addIndex( $it );
288        return $it;
289    }
290
291    /**
292     * Fetch deleted categories
293     * @param IReadableDatabase $dbr
294     * @param string $fname Name of the calling function
295     * @return BatchRowIterator
296     */
297    protected function getDeletedCatsIterator( IReadableDatabase $dbr, $fname ) {
298        $it = new BatchRowIterator( $dbr,
299            $dbr->newSelectQueryBuilder()
300                ->from( 'recentchanges' )
301                ->select( [ 'rc_cur_id', 'rc_title' ] )
302                ->where( [
303                    'rc_namespace' => NS_CATEGORY,
304                    'rc_source' => RecentChange::SRC_LOG,
305                    'rc_log_type' => 'delete',
306                    'rc_log_action' => 'delete',
307                    // We will fetch ones that do not have page record. If they do,
308                    // this means they were restored, thus restoring handler will pick it up.
309                    'NOT EXISTS (SELECT * FROM page WHERE page_id = rc_cur_id)',
310                ] )
311                ->caller( $fname ),
312            [ 'rc_timestamp' ],
313            $this->mBatchSize
314        );
315        $this->addTimestampConditions( $it, $dbr );
316        $this->addIndex( $it );
317        return $it;
318    }
319
320    /**
321     * Fetch restored categories
322     * @param IReadableDatabase $dbr
323     * @param string $fname Name of the calling function
324     * @return BatchRowIterator
325     */
326    protected function getRestoredCatsIterator( IReadableDatabase $dbr, $fname ) {
327        $it = $this->setupChangesIterator( $dbr, [], $fname );
328        $it->sqb->conds( [
329            'rc_namespace' => NS_CATEGORY,
330            'rc_source' => RecentChange::SRC_LOG,
331            'rc_log_type' => 'delete',
332            'rc_log_action' => 'restore',
333            // We will only fetch ones that have page record
334            'EXISTS (SELECT page_id FROM page WHERE page_id = rc_cur_id)',
335        ] );
336        $this->addIndex( $it );
337        return $it;
338    }
339
340    /**
341     * Fetch categorization changes or edits
342     * @param IReadableDatabase $dbr
343     * @param string $source
344     * @param string $fname Name of the calling function
345     * @return BatchRowIterator
346     */
347    protected function getChangedCatsIterator( IReadableDatabase $dbr, $source, $fname ) {
348        $it = $this->setupChangesIterator( $dbr, [], $fname );
349        $it->sqb->conds( [
350            'rc_namespace' => NS_CATEGORY,
351            'rc_source' => $source,
352        ] );
353        $this->addIndex( $it );
354        return $it;
355    }
356
357    /**
358     * Add timestamp limits to iterator
359     * @param BatchRowIterator $it Iterator
360     * @param IReadableDatabase $dbr
361     */
362    private function addTimestampConditions( BatchRowIterator $it, IReadableDatabase $dbr ) {
363        $it->sqb->conds( [
364            $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $this->startTS ) ),
365            $dbr->expr( 'rc_timestamp', '<', $dbr->timestamp( $this->endTS ) ),
366        ] );
367    }
368
369    /**
370     * Need to force index, somehow on terbium the optimizer chooses wrong one
371     */
372    private function addIndex( BatchRowIterator $it ) {
373        $it->sqb->options( [
374            'USE INDEX' => [ 'recentchanges' => 'rc_source_name_timestamp' ]
375        ] );
376    }
377
378    /**
379     * Get iterator for links for categories.
380     * @param IReadableDatabase $dbr
381     * @param int[] $ids List of page IDs
382     * @param string $fname Name of the calling function
383     * @return Traversable
384     */
385    protected function getCategoryLinksIterator( IReadableDatabase $dbr, array $ids, $fname ) {
386        $qb = $dbr->newSelectQueryBuilder()
387            ->select( [ 'cl_from', 'lt_title' ] )
388            ->from( 'categorylinks' )
389            ->join( 'linktarget', null, 'cl_target_id=lt_id' )
390            ->where( [
391                'cl_type' => 'subcat',
392                'cl_from' => $ids
393            ] )
394            ->caller( $fname );
395            $primaryKey = [ 'cl_from', 'cl_target_id' ];
396
397        $it = new BatchRowIterator(
398            $dbr,
399            $qb,
400            $primaryKey,
401            $this->mBatchSize
402        );
403        return new RecursiveIteratorIterator( $it );
404    }
405
406    /**
407     * Get accumulated RDF.
408     * @return string
409     */
410    public function getRdf() {
411        return $this->rdfWriter->drain();
412    }
413
414    /**
415     * Handle category deletes.
416     * @param IReadableDatabase $dbr
417     * @param resource $output File to write the output
418     */
419    public function handleDeletes( IReadableDatabase $dbr, $output ) {
420        // This only does "true" deletes - i.e. those that the page stays deleted
421
422        foreach ( $this->getDeletedCatsIterator( $dbr, __METHOD__ ) as $batch ) {
423            $deleteUrls = [];
424            foreach ( $batch as $row ) {
425                // This can produce duplicates, we don't care
426                $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
427                $this->processed[$row->rc_cur_id] = true;
428            }
429            fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, [], "Deletes" ) );
430        }
431    }
432
433    /**
434     * Write category data to RDF.
435     * @param stdclass $row Database row
436     */
437    private function writeCategoryData( $row ) {
438        $this->categoriesRdf->writeCategoryData(
439            $row->rc_title,
440            $row->pp_propname === 'hiddencat',
441            (int)$row->cat_pages - (int)$row->cat_subcats - (int)$row->cat_files,
442            (int)$row->cat_subcats
443        );
444    }
445
446    /**
447     * @param IReadableDatabase $dbr
448     * @param resource $output
449     */
450    public function handleMoves( IReadableDatabase $dbr, $output ) {
451        foreach ( $this->getMovedCatsIterator( $dbr, __METHOD__ ) as $batch ) {
452            $pages = [];
453            $deleteUrls = [];
454            foreach ( $batch as $row ) {
455                $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
456
457                if ( isset( $this->processed[$row->rc_cur_id] ) ) {
458                    // We already captured this one before
459                    continue;
460                }
461
462                if ( $row->page_namespace != NS_CATEGORY ) {
463                    // If page was moved out of Category:, we'll just delete
464                    continue;
465                }
466                $row->rc_title = $row->page_title;
467                $this->writeCategoryData( $row );
468                $pages[$row->rc_cur_id] = $row->page_title;
469                $this->processed[$row->rc_cur_id] = true;
470            }
471
472            fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, $pages, "Moves" ) );
473        }
474    }
475
476    /**
477     * @param IReadableDatabase $dbr
478     * @param resource $output
479     */
480    public function handleRestores( IReadableDatabase $dbr, $output ) {
481        fwrite( $output, "# Restores\n" );
482
483        // This will only find those restores that were not deleted later.
484        foreach ( $this->getRestoredCatsIterator( $dbr, __METHOD__ ) as $batch ) {
485            $pages = [];
486            foreach ( $batch as $row ) {
487                if ( isset( $this->processed[$row->rc_cur_id] ) ) {
488                    // We already captured this one before
489                    continue;
490                }
491                $this->writeCategoryData( $row );
492                $pages[$row->rc_cur_id] = $row->rc_title;
493                $this->processed[$row->rc_cur_id] = true;
494            }
495
496            if ( !$pages ) {
497                continue;
498            }
499
500            $this->writeParentCategories( $dbr, $pages );
501
502            fwrite( $output, $this->getInsertRdf() );
503        }
504    }
505
506    /**
507     * @param IReadableDatabase $dbr
508     * @param resource $output
509     */
510    public function handleAdds( IReadableDatabase $dbr, $output ) {
511        fwrite( $output, "# Additions\n" );
512
513        foreach ( $this->getNewCatsIterator( $dbr, __METHOD__ ) as $batch ) {
514            $pages = [];
515            foreach ( $batch as $row ) {
516                if ( isset( $this->processed[$row->rc_cur_id] ) ) {
517                    // We already captured this one before
518                    continue;
519                }
520                $this->writeCategoryData( $row );
521                $pages[$row->rc_cur_id] = $row->rc_title;
522                $this->processed[$row->rc_cur_id] = true;
523            }
524
525            if ( !$pages ) {
526                continue;
527            }
528
529            $this->writeParentCategories( $dbr, $pages );
530            fwrite( $output, $this->getInsertRdf() );
531        }
532    }
533
534    /**
535     * Handle edits for category texts
536     * @param IReadableDatabase $dbr
537     * @param resource $output
538     */
539    public function handleEdits( IReadableDatabase $dbr, $output ) {
540        // Editing category can change hidden flag and add new parents.
541        // TODO: it's pretty expensive to update all edited categories, and most edits
542        // aren't actually interesting for us. Some way to know which are interesting?
543        // We can capture recategorization on the next step, but not change in hidden status.
544
545        foreach ( $this->getChangedCatsIterator( $dbr, RecentChange::SRC_EDIT, __METHOD__ ) as $batch ) {
546            $pages = [];
547            $deleteUrls = [];
548            foreach ( $batch as $row ) {
549                // Note that on categorization event, cur_id points to
550                // the child page, not the parent category!
551                if ( isset( $this->processed[$row->rc_cur_id] ) ) {
552                    // We already captured this one before
553                    continue;
554                }
555                $this->writeCategoryData( $row );
556                $pages[$row->rc_cur_id] = $row->rc_title;
557                $this->processed[$row->rc_cur_id] = true;
558                $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
559            }
560
561            fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, $pages, "Edits" ) );
562        }
563    }
564
565    /**
566     * Handles categorization changes
567     * @param IReadableDatabase $dbr
568     * @param resource $output
569     */
570    public function handleCategorization( IReadableDatabase $dbr, $output ) {
571        $processedTitle = [];
572
573        // Categorization change can add new parents and change counts
574        // for the parent category.
575
576        foreach ( $this->getChangedCatsIterator( $dbr, RecentChange::SRC_CATEGORIZE, __METHOD__ ) as $batch ) {
577            /*
578             * Note that on categorization event, cur_id points to
579             * the child page, not the parent category!
580             * So we need to have a two-stage process, since we have ID from one
581             * category and title from another, and we need both for proper updates.
582             * TODO: For now, we do full update even though some data hasn't changed,
583             * e.g. parents for parent cat and counts for child cat.
584             */
585            $childPages = [];
586            $parentCats = [];
587            foreach ( $batch as $row ) {
588                $childPages[$row->rc_cur_id] = true;
589                $parentCats[$row->rc_title] = true;
590            }
591
592            $pages = [];
593            $deleteUrls = [];
594
595            if ( $childPages ) {
596                // Load child rows by ID
597                $childRows = $dbr->newSelectQueryBuilder()
598                    ->select( [
599                        'page_id',
600                        'rc_title' => 'page_title',
601                        'pp_propname',
602                        'cat_pages',
603                        'cat_subcats',
604                        'cat_files',
605                    ] )
606                    ->from( 'page' )
607                    ->leftJoin( 'page_props', null, [ 'pp_propname' => 'hiddencat', 'pp_page = page_id' ] )
608                    ->leftJoin( 'category', null, [ 'cat_title = page_title' ] )
609                    ->where( [ 'page_namespace' => NS_CATEGORY, 'page_id' => array_keys( $childPages ) ] )
610                    ->caller( __METHOD__ )->fetchResultSet();
611                foreach ( $childRows as $row ) {
612                    if ( isset( $this->processed[$row->page_id] ) ) {
613                        // We already captured this one before
614                        continue;
615                    }
616                    $this->writeCategoryData( $row );
617                    if ( $row->page_id ) {
618                        $pages[$row->page_id] = $row->rc_title;
619                        $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
620                        $this->processed[$row->page_id] = true;
621                    }
622                }
623            }
624
625            if ( $parentCats ) {
626                // Load parent rows by title
627                $parentRows = $dbr->newSelectQueryBuilder()
628                    ->select( [
629                        'page_id',
630                        'rc_title' => 'cat_title',
631                        'pp_propname',
632                        'cat_pages',
633                        'cat_subcats',
634                        'cat_files',
635                    ] )
636                    ->from( 'category' )
637                    ->leftJoin( 'page', null, [ 'page_title = cat_title', 'page_namespace' => NS_CATEGORY ] )
638                    ->leftJoin( 'page_props', null, [ 'pp_propname' => 'hiddencat', 'pp_page = page_id' ] )
639                    ->where( [ 'cat_title' => array_map( 'strval', array_keys( $parentCats ) ) ] )
640                    ->caller( __METHOD__ )->fetchResultSet();
641                foreach ( $parentRows as $row ) {
642                    if ( $row->page_id && isset( $this->processed[$row->page_id] ) ) {
643                        // We already captured this one before
644                        continue;
645                    }
646                    if ( isset( $processedTitle[$row->rc_title] ) ) {
647                        // We already captured this one before
648                        continue;
649                    }
650                    $this->writeCategoryData( $row );
651                    if ( $row->page_id ) {
652                        $pages[$row->page_id] = $row->rc_title;
653                        $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
654                        $this->processed[$row->page_id] = true;
655                    }
656                    $processedTitle[$row->rc_title] = true;
657                }
658            }
659
660            fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, $pages, "Changes" ) );
661        }
662    }
663}
664
665// @codeCoverageIgnoreStart
666$maintClass = CategoryChangesAsRdf::class;
667require_once RUN_MAINTENANCE_IF_MAIN;
668// @codeCoverageIgnoreEnd