Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.67% covered (success)
90.67%
175 / 193
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiRevisionEntitySchemaUpdater
90.67% covered (success)
90.67%
175 / 193
50.00% covered (danger)
50.00%
4 / 8
27.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 newFromContext
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 truncateSchemaTextForCommentData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 overwriteWholeSchema
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
5.03
 updateSchemaNameBadge
94.44% covered (success)
94.44%
51 / 54
0.00% covered (danger)
0.00%
0 / 1
6.01
 getUpdateNameBadgeAutocomment
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 updateSchemaText
94.12% covered (success)
94.12%
48 / 51
0.00% covered (danger)
0.00%
0 / 1
6.01
 saveRevision
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare( strict_types = 1 );
4
5namespace EntitySchema\DataAccess;
6
7use Diff\Patcher\PatcherException;
8use EntitySchema\Domain\Model\EntitySchemaId;
9use EntitySchema\MediaWiki\Content\EntitySchemaContent;
10use EntitySchema\MediaWiki\EntitySchemaServices;
11use EntitySchema\MediaWiki\HookRunner;
12use EntitySchema\Services\Converter\EntitySchemaConverter;
13use EntitySchema\Services\Converter\FullArrayEntitySchemaData;
14use MediaWiki\CommentStore\CommentStoreComment;
15use MediaWiki\Context\IContextSource;
16use MediaWiki\Languages\LanguageFactory;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Revision\RevisionLookup;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\Revision\SlotRecord;
21use MediaWiki\Storage\PageUpdater;
22
23/**
24 * @license GPL-2.0-or-later
25 */
26class MediaWikiRevisionEntitySchemaUpdater implements EntitySchemaUpdater {
27
28    public const AUTOCOMMENT_UPDATED_SCHEMATEXT = 'entityschema-summary-update-schema-text';
29    public const AUTOCOMMENT_UPDATED_NAMEBADGE = 'entityschema-summary-update-schema-namebadge';
30    public const AUTOCOMMENT_UPDATED_LABEL = 'entityschema-summary-update-schema-label';
31    public const AUTOCOMMENT_UPDATED_DESCRIPTION = 'entityschema-summary-update-schema-description';
32    public const AUTOCOMMENT_UPDATED_ALIASES = 'entityschema-summary-update-schema-aliases';
33    public const AUTOCOMMENT_RESTORE = 'entityschema-summary-restore';
34    public const AUTOCOMMENT_UNDO = 'entityschema-summary-undo';
35
36    private MediaWikiPageUpdaterFactory $pageUpdaterFactory;
37    private WatchlistUpdater $watchListUpdater;
38    private IContextSource $context;
39    private RevisionLookup $revisionLookup;
40    private LanguageFactory $languageFactory;
41    private HookRunner $hookRunner;
42
43    public function __construct(
44        MediaWikiPageUpdaterFactory $pageUpdaterFactory,
45        WatchlistUpdater $watchListUpdater,
46        IContextSource $context,
47        RevisionLookup $revisionLookup,
48        LanguageFactory $languageFactory,
49        HookRunner $hookRunner
50    ) {
51        $this->pageUpdaterFactory = $pageUpdaterFactory;
52        $this->watchListUpdater = $watchListUpdater;
53        $this->context = $context;
54        $this->revisionLookup = $revisionLookup;
55        $this->languageFactory = $languageFactory;
56        $this->hookRunner = $hookRunner;
57    }
58
59    // TODO this should probably be a service in the service container
60    public static function newFromContext( IContextSource $context ): self {
61        $services = MediaWikiServices::getInstance();
62        return new self(
63            EntitySchemaServices::getMediaWikiPageUpdaterFactory( $services ),
64            EntitySchemaServices::getWatchlistUpdater( $services ),
65            $context,
66            $services->getRevisionLookup(),
67            $services->getLanguageFactory(),
68            EntitySchemaServices::getHookRunner( $services )
69        );
70    }
71
72    private function truncateSchemaTextForCommentData( string $schemaText ): string {
73        $language = $this->languageFactory->getLanguage( 'en' );
74        return $language->truncateForVisual( $schemaText, 5000 );
75    }
76
77    /**
78     * Update a Schema with new content. This will remove existing schema content.
79     *
80     * @param EntitySchemaId $id
81     * @param string[] $labels
82     * @param string[] $descriptions
83     * @param string[][] $aliasGroups
84     * @param string $schemaText
85     * @param int $baseRevId
86     * @param CommentStoreComment $summary
87     */
88    public function overwriteWholeSchema(
89        EntitySchemaId $id,
90        array $labels,
91        array $descriptions,
92        array $aliasGroups,
93        string $schemaText,
94        int $baseRevId,
95        CommentStoreComment $summary
96    ): EntitySchemaStatus {
97        $updaterStatus = $this->pageUpdaterFactory->getPageUpdater( $id->getId(), $this->context );
98        if ( !$updaterStatus->isOK() ) {
99            return EntitySchemaStatus::wrap( $updaterStatus );
100        }
101        $status = EntitySchemaStatus::newEdit(
102            $id,
103            $updaterStatus->getSavedTempUser(),
104            $updaterStatus->getContext()
105        );
106        $updater = $updaterStatus->getPageUpdater();
107        if ( $updater->grabParentRevision() === null ) {
108            $status->fatal( 'entityschema-error-schemaupdate-failed' );
109            return $status;
110        }
111        if ( $updater->hasEditConflict( $baseRevId ) ) {
112            $status->fatal( 'edit-conflict' );
113            return $status;
114        }
115
116        $content = new EntitySchemaContent(
117            EntitySchemaEncoder::getPersistentRepresentation(
118                $id,
119                $labels,
120                $descriptions,
121                $aliasGroups,
122                $schemaText
123            )
124        );
125        $this->saveRevision( $status, $updater, $content, $summary );
126        if ( !$status->isOK() ) {
127            return $status;
128        }
129
130        $this->watchListUpdater->optionallyWatchEditedSchema( $this->context->getUser(), $id );
131
132        return $status;
133    }
134
135    public function updateSchemaNameBadge(
136        EntitySchemaId $id,
137        string $langCode,
138        string $label,
139        string $description,
140        array $aliases,
141        int $baseRevId
142    ): EntitySchemaStatus {
143        $updaterStatus = $this->pageUpdaterFactory->getPageUpdater( $id->getId(), $this->context );
144        if ( !$updaterStatus->isOK() ) {
145            return EntitySchemaStatus::wrap( $updaterStatus );
146        }
147        $status = EntitySchemaStatus::newEdit(
148            $id,
149            $updaterStatus->getSavedTempUser(),
150            $updaterStatus->getContext()
151        );
152        $updater = $updaterStatus->getPageUpdater();
153        $parentRevision = $updater->grabParentRevision();
154        if ( $parentRevision === null ) {
155            $status->fatal( 'entityschema-error-schemaupdate-failed' );
156            return $status;
157        }
158
159        $baseRevision = $this->revisionLookup->getRevisionById( $baseRevId );
160
161        $updateGuard = new EntitySchemaUpdateGuard();
162        try {
163            $schemaData = $updateGuard->guardSchemaUpdate(
164                $baseRevision,
165                $parentRevision,
166                static function ( FullArrayEntitySchemaData $schemaData ) use (
167                    $langCode,
168                    $label,
169                    $description,
170                    $aliases
171                ) {
172                    $schemaData->data['labels'][$langCode] = $label;
173                    $schemaData->data['descriptions'][$langCode] = $description;
174                    $schemaData->data['aliases'][$langCode] = $aliases;
175                }
176            );
177        } catch ( PatcherException $e ) {
178            return EntitySchemaStatus::newFatal( 'edit-conflict' );
179        }
180
181        if ( $schemaData === null ) {
182            return $status;
183        }
184
185        $autoComment = $this->getUpdateNameBadgeAutocomment(
186            $baseRevision,
187            $langCode,
188            $label,
189            $description,
190            $aliases
191        );
192
193        $content = new EntitySchemaContent(
194            EntitySchemaEncoder::getPersistentRepresentation(
195                $id,
196                $schemaData->labels,
197                $schemaData->descriptions,
198                $schemaData->aliases,
199                $schemaData->schemaText
200            )
201        );
202        $this->saveRevision( $status, $updater, $content, $autoComment );
203        if ( !$status->isOK() ) {
204            return $status;
205        }
206
207        $this->watchListUpdater->optionallyWatchEditedSchema( $this->context->getUser(), $id );
208
209        return $status;
210    }
211
212    private function getUpdateNameBadgeAutocomment(
213        RevisionRecord $baseRevision,
214        string $langCode,
215        string $label,
216        string $description,
217        array $aliases
218    ): CommentStoreComment {
219
220        $schemaConverter = new EntitySchemaConverter();
221        $schemaData = $schemaConverter->getPersistenceSchemaData(
222            // @phan-suppress-next-line PhanUndeclaredMethod
223            $baseRevision->getContent( SlotRecord::MAIN )->getText()
224        );
225
226        $label = EntitySchemaCleaner::trimWhitespaceAndControlChars( $label );
227        $description = EntitySchemaCleaner::trimWhitespaceAndControlChars( $description );
228        $aliases = EntitySchemaCleaner::cleanupArrayOfStrings( $aliases );
229        $language = $this->languageFactory->getLanguage( $langCode );
230
231        $typeOfChange = [];
232        if ( ( $schemaData->labels[$langCode] ?? '' ) !== $label ) {
233            $typeOfChange[self::AUTOCOMMENT_UPDATED_LABEL] = $label;
234        }
235        if ( ( $schemaData->descriptions[$langCode] ?? '' ) !== $description ) {
236            $typeOfChange[self::AUTOCOMMENT_UPDATED_DESCRIPTION] = $description;
237        }
238        if ( ( $schemaData->aliases[$langCode] ?? [] ) !== $aliases ) {
239            $typeOfChange[self::AUTOCOMMENT_UPDATED_ALIASES] = $language->commaList( $aliases );
240        }
241
242        if ( count( $typeOfChange ) === 1 ) { // TODO what if it’s 0?
243            $autocommentKey = key( $typeOfChange );
244            $autosummary = $typeOfChange[$autocommentKey];
245        } else {
246            $autocommentKey = self::AUTOCOMMENT_UPDATED_NAMEBADGE;
247            $autosummary = '';
248        }
249
250        $autocomment = $autocommentKey . ':' . $langCode;
251
252        return CommentStoreComment::newUnsavedComment(
253            '/* ' . $autocomment . ' */' . $autosummary,
254            [
255                'key' => $autocommentKey,
256                'language' => $langCode,
257                'label' => $label,
258                'description' => $description,
259                'aliases' => $aliases,
260            ]
261        );
262    }
263
264    /**
265     * @param EntitySchemaId $id
266     * @param string $schemaText
267     * @param int $baseRevId
268     * @param string|null $userSummary
269     */
270    public function updateSchemaText(
271        EntitySchemaId $id,
272        string $schemaText,
273        int $baseRevId,
274        ?string $userSummary = null
275    ): EntitySchemaStatus {
276        $updaterStatus = $this->pageUpdaterFactory->getPageUpdater( $id->getId(), $this->context );
277        if ( !$updaterStatus->isOK() ) {
278            return EntitySchemaStatus::wrap( $updaterStatus );
279        }
280        $status = EntitySchemaStatus::newEdit(
281            $id,
282            $updaterStatus->getSavedTempUser(),
283            $updaterStatus->getContext()
284        );
285        $updater = $updaterStatus->getPageUpdater();
286        $parentRevision = $updater->grabParentRevision();
287        if ( $parentRevision === null ) {
288            $status->fatal( 'entityschema-error-schemaupdate-failed' );
289            return $status;
290        }
291
292        $baseRevision = $this->revisionLookup->getRevisionById( $baseRevId );
293
294        $updateGuard = new EntitySchemaUpdateGuard();
295        try {
296            $schemaData = $updateGuard->guardSchemaUpdate(
297                $baseRevision,
298                $parentRevision,
299                static function ( FullArrayEntitySchemaData $schemaData ) use ( $schemaText ) {
300                    $schemaData->data['schemaText'] = $schemaText;
301                }
302            );
303        } catch ( PatcherException $e ) {
304            $status->fatal( 'edit-conflict' );
305            return $status;
306        }
307
308        if ( $schemaData === null ) {
309            return $status;
310        }
311
312        $commentText = '/* ' . self::AUTOCOMMENT_UPDATED_SCHEMATEXT . ' */' . $userSummary;
313        $summary = CommentStoreComment::newUnsavedComment(
314            $commentText,
315            [
316                'key' => self::AUTOCOMMENT_UPDATED_SCHEMATEXT,
317                'userSummary' => $userSummary,
318                'schemaText_truncated' => $this->truncateSchemaTextForCommentData(
319                    // TODO use unpatched $schemaText or patched $schemaData->schemaText here?
320                    $schemaData->schemaText
321                ),
322            ]
323        );
324
325        $persistentRepresentation = EntitySchemaEncoder::getPersistentRepresentation(
326            $id,
327            $schemaData->labels,
328            $schemaData->descriptions,
329            $schemaData->aliases,
330            $schemaData->schemaText
331        );
332
333        $content = new EntitySchemaContent( $persistentRepresentation );
334        $this->saveRevision( $status, $updater, $content, $summary );
335        if ( !$status->isOK() ) {
336            return $status;
337        }
338
339        $this->watchListUpdater->optionallyWatchEditedSchema( $this->context->getUser(), $id );
340
341        return $status;
342    }
343
344    private function saveRevision(
345        EntitySchemaStatus $status,
346        PageUpdater $updater,
347        EntitySchemaContent $content,
348        CommentStoreComment $summary
349    ): void {
350        $context = $status->getContext();
351        if ( !$this->hookRunner->onEditFilterMergedContent(
352            $context, $content, $status, $summary->text, $context->getUser(), false
353        ) ) {
354            return;
355        }
356
357        $updater->setContent( SlotRecord::MAIN, $content );
358        $updater->saveRevision(
359            $summary,
360            EDIT_UPDATE | EDIT_INTERNAL
361        );
362        $status->merge( $updater->getStatus() );
363    }
364
365}