Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 329
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoadPreDefinedObject
0.00% covered (danger)
0.00%
0 / 323
0.00% covered (danger)
0.00%
0 / 9
9702
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 1
240
 makeEdit
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
240
 mergeData
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
380
 resolveConflicts
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 findPropAndSet
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 printDiff
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 undoChange
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getOptions
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
992
1<?php
2
3/**
4 * WikiLambda loadPreDefinedObject maintenance script
5 *
6 * Loads specified pre-defined Object, range of Objects, or all pre-defined Objects into the database.
7 *
8 * @file
9 * @ingroup Extensions
10 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
11 * @license MIT
12 */
13
14use MediaWiki\Extension\WikiLambda\Diff\ZObjectDiffer;
15use MediaWiki\Extension\WikiLambda\ZErrorException;
16use MediaWiki\Extension\WikiLambda\ZObjectContent;
17use MediaWiki\Extension\WikiLambda\ZObjectStore;
18use MediaWiki\Json\FormatJson;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\Maintenance\Maintenance;
21use MediaWiki\Title\Title;
22use MediaWiki\Title\TitleFactory;
23
24$IP = getenv( 'MW_INSTALL_PATH' );
25if ( $IP === false ) {
26    $IP = __DIR__ . '/../../..';
27}
28require_once "$IP/maintenance/Maintenance.php";
29
30class LoadPreDefinedObject extends Maintenance {
31
32    /**
33     * @inheritDoc
34     */
35    public function __construct() {
36        parent::__construct();
37        $this->requireExtension( 'WikiLambda' );
38        $this->addDescription( 'Loads a specified pre-defined Object into the database' );
39
40        $this->addOption(
41            'zid',
42            'Loads the requested ZID. E.g. "--zid Z100"',
43            false,
44            true
45        );
46
47        $this->addOption(
48            'from',
49            'Loads the objects from a lower range. Must be used along with "--to". E.g. "--from Z100 --to Z200"',
50            false,
51            true
52        );
53
54        $this->addOption(
55            'to',
56            'Loads the objects till an upper range. Must be used along with "--from". E.g. "--from Z100 --to Z200"',
57            false,
58            true
59        );
60
61        $this->addOption(
62            'all',
63            'Loads all built-in objects, from Z1 to Z9999.',
64            false,
65            false
66        );
67
68        $this->addOption(
69            'force',
70            'Forces the load even if the Object already exists (clears the Object)',
71            false,
72            false
73        );
74
75        $this->addOption(
76            'merge',
77            'Updates the objects but keeps the multilingual data untouched',
78            false,
79            false
80        );
81
82        $this->addOption(
83            'builtin',
84            'On merge conflicts, automatically defaults to restoring builtin values',
85            false,
86            false
87        );
88
89        $this->addOption(
90            'current',
91            'On merge conflicts, automatically defaults to keeping the current values',
92            false,
93            false
94        );
95
96        $this->addOption(
97            'wait',
98            'Sleeps the given time (in ms) between inserts',
99            false,
100            true
101        );
102    }
103
104    /**
105     * @inheritDoc
106     */
107    public function execute() {
108        // Validate and collect arguments
109        [
110            $all,
111            $from,
112            $to,
113            $force,
114            $merge,
115            $builtin,
116            $current,
117            $wait
118        ] = $this->getOptions();
119
120        // Construct the ZObjectStore, because ServiceWiring hasn't run
121        $services = $this->getServiceContainer();
122        $titleFactory = $services->getTitleFactory();
123        $zObjectStore = new ZObjectStore(
124            $services->getDBLoadBalancerFactory(),
125            $services->getTitleFactory(),
126            $services->getWikiPageFactory(),
127            $services->getRevisionStore(),
128            $services->getUserGroupManager(),
129            LoggerFactory::getInstance( 'WikiLambda' )
130        );
131
132        // Base path:
133        $path = dirname( __DIR__ ) . '/function-schemata/data/definitions/';
134
135        // Get dependencies file
136        $dependencies = [];
137        $dependenciesFile = file_get_contents( $path . 'dependencies.json' );
138        if ( $dependenciesFile === false ) {
139            $this->fatalError(
140                'Could not load dependencies file from function-schemata sub-repository of the WikiLambda extension.'
141                . ' Have you initiated & fetched it? Try `git submodule update --init --recursive`.'
142            );
143        }
144        $dependenciesIndex = json_decode( $dependenciesFile, true );
145
146        // Get data files
147        $initialDataToLoadListing = array_filter(
148            scandir( $path ),
149            static function ( $key ) use ( $from, $to ) {
150                if ( preg_match( '/^Z(\d+)\.json$/', $key, $match ) ) {
151                    if ( $match[1] >= $from && $match[1] <= $to ) {
152                        return true;
153                    }
154                }
155                return false;
156            }
157        );
158
159        // Get zids to load
160        $zidsToLoad = array_map(
161            static function ( string $filename ): string {
162                return substr( $filename, 0, -5 );
163            },
164            $initialDataToLoadListing
165        );
166
167        // Naturally sort, so Z2 gets created before Z12 etc.
168        natsort( $zidsToLoad );
169
170        $success = 0;
171        $failure = 0;
172        $skipped = 0;
173
174        foreach ( $zidsToLoad as $zid ) {
175            // Gather dependencies
176            $dependencies = array_merge( $dependencies, $dependenciesIndex[ $zid ] ?? [] );
177
178            // Make edit
179            $response = $this->makeEdit(
180                $zid,
181                $path,
182                $titleFactory,
183                $zObjectStore,
184                $force,
185                $merge,
186                $builtin,
187                $current
188            );
189
190            // Wait requested ms
191            usleep( $wait * 1000 );
192
193            switch ( $response ) {
194                case 1:
195                    $success++;
196                    break;
197
198                case -1:
199                    $failure++;
200                    break;
201
202                case 0:
203                    $skipped++;
204                    break;
205
206                default:
207                    throw new RuntimeException( 'Unrecognised return value!' );
208            }
209        }
210
211        $this->output( "\nDone!\n" );
212
213        if ( $success > 0 ) {
214            $this->output( "$success objects were created or updated successfully.\n" );
215        }
216
217        if ( $skipped > 0 ) {
218            $this->output( "$skipped objects were skipped.\n" );
219        }
220
221        if ( $failure > 0 ) {
222            $this->fatalError( "$failure objects failed to create or update.\n" );
223        }
224
225        // Print dependency warning if one zid or a partial range were inserted
226        if ( !$all ) {
227            // Unique zids:
228            $dependencies = array_unique( $dependencies );
229            // Exclude inserted zids:
230            $dependencies = array_filter( $dependencies, static function ( string $item ) use ( $zidsToLoad ) {
231                return !in_array( $item, $zidsToLoad );
232            } );
233            // Sort naturally:
234            natsort( $dependencies );
235            // Output dependency notice:
236            if ( count( $dependencies ) > 0 ) {
237                $this->output( "\nMake sure the following dependencies are inserted and up to date:\n" );
238                $this->output( implode( ', ', $dependencies ) . "\n" );
239            }
240        }
241    }
242
243    /**
244     * Pushes the given object with the version available in the
245     * zobject builtin data definitions directory. If the object is
246     * already available, forces a full override or merges with the
247     * current data depending on the --force or --merge flags
248     *
249     * @param string $zid
250     * @param string $path
251     * @param TitleFactory $titleFactory
252     * @param ZObjectStore $zObjectStore
253     * @param bool $force
254     * @param bool $merge
255     * @param bool $builtin
256     * @param bool $current
257     * @return int 1=success, -1=failure, 0=skipped
258     */
259    private function makeEdit(
260        string $zid,
261        string $path,
262        TitleFactory $titleFactory,
263        ZObjectStore $zObjectStore,
264        bool $force,
265        bool $merge,
266        bool $builtin,
267        bool $current
268    ) {
269        $data = file_get_contents( $path . $zid . '.json' );
270        // If no data in builtins folder, return error
271        if ( !$data ) {
272            $this->error( 'The ZObject "' . $zid . '" was not found in the definitions folder.' );
273            return -1;
274        }
275
276        $title = $titleFactory->newFromText( $zid, NS_MAIN );
277        // If title is invalid, return error
278        if ( !( $title instanceof Title ) ) {
279            $this->error( 'The ZObject title "' . $zid . '" could not be loaded somehow; invalid name?' );
280            return -1;
281        }
282
283        $mergeSummary = '';
284        $creating = !$title->exists();
285        // If the object already exists:
286        if ( !$creating ) {
287            // Get current ZObjectContent, returns false if not found
288            $oldContent = $zObjectStore->fetchZObjectByTitle( $title );
289
290            // If merge flag is passed, merge builtin and current versions
291            if ( $merge && $oldContent ) {
292                '@phan-var ZObjectContent $oldContent';
293                // 1. Automatic merge; multilingual data, tests, implementations, etc.
294                $data = $this->mergeData( $oldContent, $data );
295                // 2. Supervised merge; check the diff and prompt user for confirmation
296                [ $data, $conflicts ] = $this->resolveConflicts( $zid, $oldContent, $data, $builtin, $current );
297                // Set summary with number of resolved conflicts (if any)
298                if ( $conflicts > 0 ) {
299                    $mergeSummary = "($conflicts conflicts)";
300                }
301            }
302
303            // If no merge and no force flags are passed, exit
304            if ( !$force && !$merge ) {
305                $this->error( 'The ZObject "' . $zid . '" already exists and --force or --merge were not set.' );
306                return 0;
307            }
308        }
309
310        $summary = wfMessage(
311            $creating
312                ? 'wikilambda-bootstrapcreationeditsummary'
313                : 'wikilambda-bootstrapupdatingeditsummary'
314        )->inLanguage( 'en' )->text();
315
316        // We create or update the ZObject
317        try {
318            $return = $zObjectStore->pushZObject( $zid, $data, $summary );
319            $this->output( ( $creating ? 'Created' : 'Updated' ) . " $zid $mergeSummary\n" );
320            return 1;
321        } catch ( ZErrorException $e ) {
322            $this->error( "Problem " . ( $creating ? 'creating' : 'updating' ) . " $zid:" );
323            $this->error( $e->getMessage() );
324            $this->error( $e->getZErrorMessage() );
325            $this->error( "\n" );
326            return -1;
327        } catch ( \Exception $e ) {
328            $this->error( "Problem " . ( $creating ? 'creating' : 'updating' ) . " $zid:" );
329            $this->error( $e->getMessage() );
330            $this->error( $e->getTraceAsString() );
331            $this->error( "\n" );
332            return -1;
333        }
334    }
335
336    /**
337     * Automatic merge of current object (old) and builtin version (new).
338     * This keeps all the data that we know we need to keep from the
339     * current stored object, which includes:
340     * - For every object: multilingual data
341     * - For functions: list of tests and implementations
342     * - For types: type functions (equality, validator, renderer, parser
343     *   and lists of converters from/to code)
344     *
345     * @param ZObjectContent $oldContent
346     * @param string $data
347     * @return string
348     */
349    private function mergeData( $oldContent, $data ) {
350        $parsedData = FormatJson::parse( $data );
351        $newObject = $parsedData->getValue();
352        $oldObject = $oldContent->getObject();
353
354        // 1. Keep whole Z2K3/Name key
355        $newObject->Z2K3 = $oldObject->Z2K3;
356
357        // 2. Keep whole Z2K4/Aliases key (if any)
358        if ( property_exists( $oldObject, 'Z2K4' ) ) {
359            $newObject->Z2K4 = $oldObject->Z2K4;
360        }
361
362        // 3. Keep whole Z2K5/Description key (if any)
363        if ( property_exists( $oldObject, 'Z2K5' ) ) {
364            $newObject->Z2K5 = $oldObject->Z2K5;
365        }
366
367        // 4. Keep type-specific content
368        $type = $oldObject->Z2K2->Z1K1;
369        switch ( $type ) {
370            // 4.a: For types:
371            case 'Z4':
372                // 4.a.1: For each key, keep whole content of Z3K3/Key label:
373                foreach ( $oldObject->Z2K2->Z4K2 as $index => $oldKey ) {
374                    // Skip benjamin array type item
375                    if ( $index === 0 ) {
376                        continue;
377                    }
378                    $newObject->Z2K2->Z4K2[ $index ]->Z3K3 = $oldKey->Z3K3;
379                }
380
381                // 4.a.2: Keep current validator/Z4K3, equality/Z4K4, renderer/Z4K5 and parser/Z4K6
382                if ( property_exists( $oldObject->Z2K2, 'Z4K3' ) ) {
383                    $newObject->Z2K2->Z4K3 = $oldObject->Z2K2->Z4K3;
384                }
385                if ( property_exists( $oldObject->Z2K2, 'Z4K4' ) ) {
386                    $newObject->Z2K2->Z4K4 = $oldObject->Z2K2->Z4K4;
387                }
388                if ( property_exists( $oldObject->Z2K2, 'Z4K5' ) ) {
389                    $newObject->Z2K2->Z4K5 = $oldObject->Z2K2->Z4K5;
390                }
391                if ( property_exists( $oldObject->Z2K2, 'Z4K6' ) ) {
392                    $newObject->Z2K2->Z4K6 = $oldObject->Z2K2->Z4K6;
393                }
394
395                // 4.a.3: Keep current converters (Z4K7 and Z4K8)
396                if ( property_exists( $oldObject->Z2K2, 'Z4K7' ) ) {
397                    $newObject->Z2K2->Z4K7 = $oldObject->Z2K2->Z4K7;
398                }
399                if ( property_exists( $oldObject->Z2K2, 'Z4K8' ) ) {
400                    $newObject->Z2K2->Z4K8 = $oldObject->Z2K2->Z4K8;
401                }
402
403                break;
404
405            // 4.b: For functions:
406            case 'Z8':
407                // 4.b.1: For each arg, keep whole content of Z17K3/Input label:
408                foreach ( $oldObject->Z2K2->Z8K1 as $index => $oldArg ) {
409                    // Skip benjamin array type item
410                    if ( $index === 0 ) {
411                        continue;
412                    }
413                    $newObject->Z2K2->Z8K1[ $index ]->Z17K3 = $oldArg->Z17K3;
414                }
415
416                // 4.b.2: Keep current list of tests/Z8K3
417                $newObject->Z2K2->Z8K3 = $oldObject->Z2K2->Z8K3;
418
419                // 4.b.3: Keep current list of implementations/Z8K4
420                $newObject->Z2K2->Z8K4 = $oldObject->Z2K2->Z8K4;
421
422                break;
423
424            // For error types: For each key, copy whole Z3K3 key
425            case 'Z50':
426                foreach ( $oldObject->Z2K2->Z50K1 as $index => $oldKey ) {
427                    // Skip benjamin array type item
428                    if ( $index === 0 ) {
429                        continue;
430                    }
431                    $newObject->Z2K2->Z50K1[ $index ]->Z3K3 = $oldKey->Z3K3;
432                }
433                break;
434
435            default:
436                break;
437        }
438
439        return FormatJson::encode( $newObject, true, FormatJson::UTF8_OK );
440    }
441
442    /**
443     * Supervised merge of current object (old) and builtin version (new).
444     * This tries to merge all other diffs that were not automatically merged
445     * during the mergeData step, and requests input from the user to keep
446     * the current version or restore the builtin value.
447     * The builtin and current flags will automatically run the script without
448     * requesting user input.
449     * Returns a list with the final object encoded as a string, and the count
450     * of resolved conflicts.
451     *
452     * @param string $zid
453     * @param ZObjectContent $oldContent
454     * @param string $data
455     * @param bool $builtin
456     * @param bool $current
457     * @return array list( string: $data, int: $conflicts )
458     */
459    private function resolveConflicts( $zid, $oldContent, $data, $builtin, $current ) {
460        $differ = new ZObjectDiffer();
461        $diffOps = $differ->doDiff(
462            /* oldValues: current content stored in the DB */
463            json_decode( json_encode( $oldContent->getObject() ), true ),
464            /* newValues: content from builtin data to restore */
465            json_decode( $data, true )
466        );
467
468        $flats = ZObjectDiffer::flattenDiff( $diffOps );
469        foreach ( $flats as $diff ) {
470            $restoreBuiltin = $builtin;
471            // If no --builtin or --current flags were passed, request interactive input
472            if ( !$builtin && !$current ) {
473                $this->printDiff( $zid, $diff );
474                $prompt = $this->prompt( '> Restore to builtin value? (y/n)', 'n' );
475                $restoreBuiltin = $prompt === 'y';
476            }
477            if ( !$restoreBuiltin ) {
478                $data = $this->undoChange( $data, $diff );
479            }
480        }
481
482        // Return new version and count of resolved conflicts
483        return [ $data, count( $flats ) ];
484    }
485
486    /**
487     * Walk the tree in depth following the keys passed in the path
488     * array, and set the new value when arrive to the leaf. If the
489     * new value is null, unset the key.
490     *
491     * @param array &$object reference to the associative array to mutate
492     * @param array $path array of keys to follow down the object
493     * @param string|array|null $newValue new value to set
494     */
495    private function findPropAndSet( &$object, $path, $newValue = null ) {
496        $head = array_shift( $path );
497        if ( count( $path ) === 0 ) {
498            if ( $newValue ) {
499                $object[ $head ] = $newValue;
500            } else {
501                unset( $object[ $head ] );
502            }
503        } else {
504            if ( isset( $object[ $head ] ) ) {
505                $this->findPropAndSet( $object[ $head ], $path, $newValue );
506            }
507        }
508    }
509
510    /**
511     * Print the details of the Diff to merge.
512     *
513     * @param string $zid
514     * @param array $diff
515     */
516    private function printDiff( $zid, $diff ) {
517        $type = $diff[ 'op' ]->getType();
518        $oldValue = ( $type === 'change' || $type === 'remove' ) ? $diff[ 'op' ]->getOldValue() : null;
519        $newValue = ( $type === 'change' || $type === 'add' ) ? $diff[ 'op' ]->getNewValue() : null;
520
521        $this->output( "> Conflict:\n" );
522        $this->output( "  | Zid: $zid\n" );
523        $this->output( "  | Path: " . implode( '.', $diff[ 'path' ] ) . "\n" );
524        $this->output( "  | Current value: " . json_encode( $oldValue ) . "\n" );
525        $this->output( "  | Builtin value: " . json_encode( $newValue ) . "\n" );
526    }
527
528    /**
529     * Restores the old value of the Diff operation.
530     *
531     * @param string $data
532     * @param array $diff
533     * @return string
534     */
535    private function undoChange( $data, $diff ) {
536        $newObject = json_decode( $data, true );
537        $path = $diff[ 'path' ];
538
539        $type = $diff[ 'op' ]->getType();
540        $oldValue = ( $type === 'change' || $type === 'remove' ) ? $diff[ 'op' ]->getOldValue() : null;
541
542        $this->findPropAndSet( $newObject, $path, $oldValue );
543
544        return FormatJson::encode( $newObject, true, FormatJson::UTF8_OK );
545    }
546
547    /**
548     * Validates the options
549     *
550     * @return array
551     */
552    private function getOptions() {
553        // Get and validate --wait
554        $wait = $this->getOption( 'wait' );
555        if ( $wait && !is_numeric( $wait ) ) {
556            $this->fatalError( 'The flag "--wait" should be used with a numeric value. E.g. "--wait 100"' );
557        }
558
559        // Get and validate --force and --merge flags
560        $force = $this->getOption( 'force' ) ?? false;
561        $merge = $this->getOption( 'merge' ) ?? false;
562        if ( $force && $merge ) {
563            $this->fatalError( 'The flags "--force" and "--merge" should be mutually exclusive:' . "\n"
564                . 'Use "--force" if you want to fully override the existing content with '
565                . 'the initial builtin version.' . "\n"
566                . 'Use "--merge if you want to re-insert the builtin versions but keep existing '
567                . 'multilingual data of every object.' );
568        }
569
570        // Get --current and --builtin only if --merge is set to true
571        $current = $this->getOption( 'current' ) ?? false;
572        $builtin = $this->getOption( 'builtin' ) ?? false;
573        if ( !$merge && ( $current || $builtin ) ) {
574            $this->fatalError( 'The flags "--current" or "--builtin" should only be used along with "--merge".' );
575        }
576        if ( $current && $builtin ) {
577            $this->fatalError( 'The flags "--current" and "--builtin" should be mutually exclusive:' . "\n"
578                . 'Use "--merge --builtin" to automatically default to restoring builtin versions.' . "\n"
579                . 'Use "--merge --current" to automatically default to keeping current stored versions.' );
580        }
581
582        // Get and validate Zid range to insert:
583        $all = $this->getOption( 'all' ) ?? false;
584        $from = $this->getOption( 'from' );
585        $to = $this->getOption( 'to' );
586        $zid = $this->getOption( 'zid' );
587
588        if ( $all ) {
589            // --all option overrides any --from and --to passed as arguments
590            if ( (bool)$from || (bool)$to || (bool)$zid ) {
591                $this->fatalError( 'The flag "--all" should not be used along with "--for", "--to" or "--zid".' );
592            }
593
594            $from = 1;
595            $to = 9999;
596        } elseif ( $zid ) {
597            // Remove Z or z
598            if ( strtoupper( substr( $zid, 0, 1 ) ) === 'Z' ) {
599                $zid = substr( $zid, 1 );
600            }
601
602            if ( !is_numeric( $zid ) || $zid < 1 || $zid > 9999 ) {
603                $this->fatalError( 'The flag "--zid" must be a number between 1 and 9999.' );
604            }
605
606            if ( (bool)$from || (bool)$to ) {
607                $this->fatalError( 'The flag "--zid" should not be used along with "--for", "--to" or "--all".' );
608            }
609
610            $from = $zid;
611            $to = $zid;
612        } else {
613            // If no --all and no --zid are entered, then --from and --to are mandatory
614            if ( (bool)$from xor (bool)$to ) {
615                $this->fatalError( 'The flag "--from" must be used with the flag "--to" to set a range.' );
616            }
617
618            // Remove Z or z
619            if ( strtoupper( substr( $from, 0, 1 ) ) === 'Z' ) {
620                $from = substr( $from, 1 );
621            }
622
623            // Remove Z or z
624            if ( strtoupper( substr( $to, 0, 1 ) ) === 'Z' ) {
625                $to = substr( $to, 1 );
626            }
627
628            if ( !is_numeric( $from ) || $from < 1 || $from > 9999 ) {
629                $this->fatalError( 'The flag "--from" must be followed by a Zid between Z1 and Z9999.' );
630            }
631
632            if ( !is_numeric( $to ) || $to < 1 || $to > 9999 ) {
633                $this->fatalError( 'The flag "--to" must be followed by a Zid between Z1 and Z9999.' );
634            }
635
636            if ( $from > $to ) {
637                $this->fatalError( 'The flag "--from" must be lower than the flag "--to".' );
638            }
639        }
640
641        return [
642            $all,
643            (int)$from,
644            (int)$to,
645            $force,
646            $merge,
647            $builtin,
648            $current,
649            (int)$wait
650        ];
651    }
652}
653
654$maintClass = LoadPreDefinedObject::class;
655require_once RUN_MAINTENANCE_IF_MAIN;