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