EntitySavingHelper::loadEntity()   F
last analyzed

Complexity

Conditions 14
Paths 301

Size

Total Lines 81

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 81
rs 3.7512
c 0
b 0
f 0
cc 14
nc 301
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare( strict_types = 1 );
4
5
namespace Wikibase\Repo\Api;
6
7
use ApiBase;
8
use ApiUsageException;
9
use ArrayAccess;
10
use InvalidArgumentException;
11
use LogicException;
12
use MediaWiki\Permissions\PermissionManager;
13
use OutOfBoundsException;
14
use Status;
15
use Wikibase\DataModel\Entity\EntityDocument;
16
use Wikibase\DataModel\Entity\EntityId;
17
use Wikibase\DataModel\Entity\EntityIdParser;
18
use Wikibase\Lib\EntityFactory;
19
use Wikibase\Lib\FormatableSummary;
20
use Wikibase\Lib\Store\EntityRevisionLookup;
21
use Wikibase\Lib\Store\EntityStore;
22
use Wikibase\Lib\Store\LookupConstants;
23
use Wikibase\Lib\Store\StorageException;
24
use Wikibase\Repo\EditEntity\EditEntity;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Wikibase\Repo\Api\EditEntity.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
25
use Wikibase\Repo\EditEntity\MediawikiEditEntityFactory;
26
use Wikibase\Repo\SummaryFormatter;
27
28
/**
29
 * Helper class for api modules to save entities.
30
 *
31
 * @license GPL-2.0-or-later
32
 * @author Addshore
33
 * @author Daniel Kinzler
34
 */
35
class EntitySavingHelper extends EntityLoadingHelper {
36
37
	public const ASSIGN_FRESH_ID = 'assignFreshId';
38
	public const NO_FRESH_ID = 'noFreshId';
39
40
	/**
41
	 * @var SummaryFormatter
42
	 */
43
	private $summaryFormatter;
44
45
	/**
46
	 * @var MediawikiEditEntityFactory
47
	 */
48
	private $editEntityFactory;
49
50
	/**
51
	 * @var PermissionManager
52
	 */
53
	private $permissionManager;
54
55
	/**
56
	 * Flags to pass to EditEntity::attemptSave; This is set by loadEntity() to EDIT_NEW
57
	 * for new entities, and EDIT_UPDATE for existing entities.
58
	 *
59
	 * @see EditEntity::attemptSave
60
	 * @see WikiPage::doEditContent
61
	 *
62
	 * @var int
63
	 */
64
	private $entitySavingFlags = 0;
65
66
	/**
67
	 * Entity ID of the loaded entity.
68
	 *
69
	 * @var EntityId|null
70
	 */
71
	private $entityId = null;
72
73
	/**
74
	 * Base revision ID, for loading the entity revision for editing, and for avoiding
75
	 * race conditions.
76
	 *
77
	 * @var int
78
	 */
79
	private $baseRevisionId = 0;
80
81
	/**
82
	 * @var EntityFactory|null
83
	 */
84
	private $entityFactory = null;
85
86
	/**
87
	 * @var EntityStore|null
88
	 */
89
	private $entityStore = null;
90
91
	public function __construct(
92
		ApiBase $apiModule,
93
		EntityIdParser $idParser,
94
		EntityRevisionLookup $entityRevisionLookup,
95
		ApiErrorReporter $errorReporter,
96
		SummaryFormatter $summaryFormatter,
97
		MediawikiEditEntityFactory $editEntityFactory,
98
		PermissionManager $permissionManager
99
	) {
100
		parent::__construct( $apiModule, $idParser, $entityRevisionLookup, $errorReporter );
101
102
		$this->summaryFormatter = $summaryFormatter;
103
		$this->editEntityFactory = $editEntityFactory;
104
		$this->permissionManager = $permissionManager;
105
106
		$this->defaultRetrievalMode = LookupConstants::LATEST_FROM_MASTER;
107
	}
108
109
	public function getBaseRevisionId(): int {
110
		return $this->baseRevisionId;
111
	}
112
113
	public function getSaveFlags(): int {
114
		return $this->entitySavingFlags;
115
	}
116
117
	public function getEntityFactory(): ?EntityFactory {
118
		return $this->entityFactory;
119
	}
120
121
	public function setEntityFactory( EntityFactory $entityFactory ): void {
122
		$this->entityFactory = $entityFactory;
123
	}
124
125
	public function getEntityStore(): ?EntityStore {
126
		return $this->entityStore;
127
	}
128
129
	public function setEntityStore( EntityStore $entityStore ): void {
130
		$this->entityStore = $entityStore;
131
	}
132
133
	/**
134
	 * @param EntityId|null $entityId ID of the entity to load. If not given, the ID is taken
135
	 *        from the request parameters. If $entityId is given, the 'baserevid' parameter must
136
	 *        belong to it.
137
	 * @param string $assignFreshId Whether to allow assigning entity ids to new entities.
138
	 *        Either of the ASSIGN_FRESH_ID/NO_FRESH_ID constants.
139
	 *        NOTE: We usually need to assign an ID early, for things like the ClaimIdGenerator.
140
	 *
141
	 * @throws ApiUsageException
142
	 *
143
	 * @return EntityDocument
144
	 */
145
	public function loadEntity( ?EntityId $entityId = null, $assignFreshId = self::ASSIGN_FRESH_ID ): EntityDocument {
146
		if ( !in_array( $assignFreshId, [ self::ASSIGN_FRESH_ID, self::NO_FRESH_ID ] ) ) {
147
			throw new InvalidArgumentException(
148
				'$assignFreshId must be either of the EntitySavingHelper::ASSIGN_FRESH_ID/NO_FRESH_ID constants.'
149
			);
150
		}
151
152
		$params = $this->apiModule->extractRequestParams();
153
154
		if ( !$entityId ) {
155
			$entityId = $this->getEntityIdFromParams( $params );
156
		}
157
158
		// If a base revision is given, use if for consistency!
159
		$baseRev = isset( $params['baserevid'] )
160
			? (int)$params['baserevid']
161
			: 0;
162
163
		if ( $entityId ) {
164
			$entityRevision = $this->loadEntityRevision( $entityId, $baseRev );
165
		} else {
166
			if ( $baseRev > 0 ) {
167
				$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
168
					'Cannot load specific revision ' . $baseRev . ' if no entity is defined.',
169
					'param-illegal'
170
				);
171
			}
172
173
			$entityRevision = null;
174
		}
175
176
		$new = $params['new'] ?? null;
177
		if ( $entityRevision === null ) {
178
			if ( $baseRev > 0 ) {
179
				$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
180
					'Could not find revision ' . $baseRev,
181
					'nosuchrevid'
182
				);
183
			}
184
185
			if ( !$this->isEntityCreationSupported() ) {
186
				if ( !$entityId ) {
187
					$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
188
						'No entity ID provided, and entity cannot be created',
189
						'no-entity-id'
190
					);
191
				} else {
192
					$this->errorReporter->dieWithError( [ 'no-such-entity', $entityId ],
0 ignored issues
show
Documentation introduced by
array('no-such-entity', $entityId) is of type array<integer,string|obj...l\\Entity\\EntityId>"}>, but the function expects a string|array<integer,str...bject<MessageSpecifier>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
193
						'no-such-entity'
194
					);
195
				}
196
			}
197
198
			if ( !$entityId && !$new ) {
199
				$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
200
					'No entity was identified, nor was creation requested',
201
					'no-entity-id'
202
				);
203
			}
204
205
			if ( $entityId && !$this->entityStore->canCreateWithCustomId( $entityId ) ) {
206
				$this->errorReporter->dieWithError( [ 'no-such-entity', $entityId ],
0 ignored issues
show
Documentation introduced by
array('no-such-entity', $entityId) is of type array<integer,string|obj...l\\Entity\\EntityId>"}>, but the function expects a string|array<integer,str...bject<MessageSpecifier>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
207
					'no-such-entity'
208
				);
209
			}
210
211
			$entity = $this->createEntity( $new, $entityId, $assignFreshId );
212
213
			$this->entitySavingFlags = EDIT_NEW;
214
			$this->baseRevisionId = 0;
215
		} else {
216
			$this->entitySavingFlags = EDIT_UPDATE;
217
			$this->baseRevisionId = $entityRevision->getRevisionId();
218
			$entity = $entityRevision->getEntity();
219
		}
220
221
		// remember the entity ID
222
		$this->entityId = $entity->getId();
223
224
		return $entity;
225
	}
226
227
	private function isEntityCreationSupported(): bool {
228
		return $this->entityStore !== null && $this->entityFactory !== null;
229
	}
230
231
	/**
232
	 * Create an empty entity.
233
	 *
234
	 * @param string|null $entityType The type of entity to create. Optional if an ID is given.
235
	 * @param EntityId|null $customId Optionally assigns a specific ID instead of generating a new
236
	 *  one.
237
	 * @param string $assignFreshId Either of the ASSIGN_FRESH_ID/NO_FRESH_ID constants
238
	 *               NOTE: We usually need to assign an ID early, for things like the ClaimIdGenerator.
239
	 *
240
	 * @throws InvalidArgumentException when entity type and ID are given but do not match.
241
	 * @throws ApiUsageException
242
	 * @throws LogicException
243
	 * @return EntityDocument
244
	 */
245
	private function createEntity( $entityType, EntityId $customId = null, $assignFreshId = self::ASSIGN_FRESH_ID ): EntityDocument {
246
		if ( $customId ) {
247
			$entityType = $customId->getEntityType();
248
		} elseif ( !$entityType ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $entityType of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
249
			$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
250
				"No entity type provided for creation!",
251
				'no-entity-type'
252
			);
253
254
			throw new LogicException( 'ApiErrorReporter::dieError did not throw an exception' );
255
		}
256
257
		try {
258
			$entity = $this->entityFactory->newEmpty( $entityType );
259
		} catch ( OutOfBoundsException $ex ) {
260
			$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
261
				"No such entity type: '$entityType'",
262
				'no-such-entity-type'
263
			);
264
265
			throw new LogicException( 'ApiErrorReporter::dieError did not throw an exception' );
266
		}
267
268
		if ( $customId !== null ) {
269
			if ( !$this->entityStore->canCreateWithCustomId( $customId ) ) {
270
				$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
271
					"Cannot create entity with ID: '$customId'",
272
					'bad-entity-id'
273
				);
274
275
				throw new LogicException( 'ApiErrorReporter::dieError did not throw an exception' );
276
			}
277
278
			$entity->setId( $customId );
279
		} elseif ( $assignFreshId === self::ASSIGN_FRESH_ID ) {
280
			try {
281
				$this->entityStore->assignFreshId( $entity );
282
			} catch ( StorageException $e ) {
283
				$this->errorReporter->dieError(
0 ignored issues
show
Deprecated Code introduced by
The method Wikibase\Repo\Api\ApiErrorReporter::dieError() has been deprecated with message: Use dieWithError() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
284
					'Cannot automatically assign ID: ' . $e->getMessage(),
285
					'no-automatic-entity-id'
286
				);
287
288
				throw new LogicException( 'ApiErrorReporter::dieError did not throw an exception' );
289
			}
290
291
		}
292
293
		return $entity;
294
	}
295
296
	/**
297
	 * Attempts to save the new entity content, while first checking for permissions,
298
	 * edit conflicts, etc. Saving is done via EditEntityHandler::attemptSave().
299
	 *
300
	 * This method automatically takes into account several parameters:
301
	 * * 'bot' for setting the bot flag
302
	 * * 'baserevid' for determining the edit's base revision for conflict resolution
303
	 * * 'token' for the edit token
304
	 * * 'tags' for change tags, assuming they were already permission checked by ApiBase
305
	 *   (i.e. PARAM_TYPE => 'tags')
306
	 *
307
	 * If an error occurs, it is automatically reported and execution of the API module
308
	 * is terminated using the ApiErrorReporter (via handleStatus()). If there were any
309
	 * warnings, they will automatically be included in the API call's output (again, via
310
	 * handleStatus()).
311
	 *
312
	 * @param EntityDocument $entity The entity to save
313
	 * @param string|FormatableSummary $summary The edit summary
314
	 * @param int $flags The edit flags (see WikiPage::doEditContent)
315
	 *
316
	 * @throws LogicException if not in write mode
317
	 * @return Status the status of the save operation, as returned by EditEntityHandler::attemptSave()
318
	 * @see  EditEntityHandler::attemptSave()
319
	 */
320
	public function attemptSaveEntity( EntityDocument $entity, $summary, int $flags = 0 ): Status {
321
		if ( !$this->apiModule->isWriteMode() ) {
322
			// sanity/safety check
323
			throw new LogicException(
324
				'attemptSaveEntity() cannot be used by API modules that do not return true from isWriteMode()!'
325
			);
326
		}
327
328
		if ( $this->entityId !== null && !$entity->getId()->equals( $this->entityId ) ) {
329
			// sanity/safety check
330
			throw new LogicException(
331
				'attemptSaveEntity() was expecting to be called on '
332
					. $this->entityId->getSerialization() . '!'
333
			);
334
		}
335
336
		if ( $summary instanceof FormatableSummary ) {
337
			$summary = $this->summaryFormatter->formatSummary( $summary );
338
		}
339
340
		$params = $this->apiModule->extractRequestParams();
341
		$user = $this->apiModule->getContext()->getUser();
342
343
		if ( isset( $params['bot'] ) && $params['bot'] &&
344
			$this->permissionManager->userHasRight( $user, 'bot' )
345
		) {
346
			$flags |= EDIT_FORCE_BOT;
347
		}
348
349
		if ( !$this->baseRevisionId ) {
350
			$this->baseRevisionId = isset( $params['baserevid'] ) ? (int)$params['baserevid'] : 0;
351
		}
352
353
		$tags = $params['tags'] ?? [];
354
355
		$editEntityHandler = $this->editEntityFactory->newEditEntity(
356
			$user,
357
			$entity->getId(),
358
			$this->baseRevisionId,
359
			true
360
		);
361
362
		$token = $this->evaluateTokenParam( $params );
363
364
		$status = $editEntityHandler->attemptSave(
365
			$entity,
366
			$summary,
367
			$this->entitySavingFlags | $flags,
368
			$token,
0 ignored issues
show
Bug introduced by
It seems like $token defined by $this->evaluateTokenParam($params) on line 362 can also be of type null; however, Wikibase\Repo\EditEntity\EditEntity::attemptSave() does only seem to accept string|boolean, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
369
			null,
370
			$tags
371
		);
372
373
		$this->handleSaveStatus( $status );
374
		return $status;
375
	}
376
377
	/**
378
	 * @param array $params
379
	 *
380
	 * @return string|bool|null Token string, or false if not needed, or null if not set.
381
	 */
382
	private function evaluateTokenParam( array $params ) {
383
		if ( !$this->apiModule->needsToken() ) {
384
			// False disables the token check.
385
			return false;
386
		}
387
388
		// Null fails the token check.
389
		return $params['token'] ?? null;
390
	}
391
392
	/**
393
	 * Signal errors and warnings from a save operation to the API call's output.
394
	 * This is much like handleStatus(), but specialized for Status objects returned by
395
	 * EditEntityHandler::attemptSave(). In particular, the 'errorFlags' and 'errorCode' fields
396
	 * from the status value are used to determine the error code to return to the caller.
397
	 *
398
	 * @note this function may or may not return normally, depending on whether
399
	 *        the status is fatal or not.
400
	 *
401
	 * @see handleStatus().
402
	 *
403
	 * @param Status $status The status to report
404
	 */
405
	private function handleSaveStatus( Status $status ): void {
406
		$value = $status->getValue();
407
		$errorCode = null;
408
409
		if ( $this->isArrayLike( $value ) && isset( $value['errorCode'] ) ) {
410
			$errorCode = $value['errorCode'];
411
		} else {
412
			$editError = 0;
413
414
			if ( $this->isArrayLike( $value ) && isset( $value['errorFlags'] ) ) {
415
				$editError = $value['errorFlags'];
416
			}
417
418
			if ( $editError & EditEntity::TOKEN_ERROR ) {
419
				$errorCode = 'badtoken';
420
			} elseif ( $editError & EditEntity::EDIT_CONFLICT_ERROR ) {
421
				$errorCode = 'editconflict';
422
			} elseif ( $editError & EditEntity::ANY_ERROR ) {
423
				$errorCode = 'failed-save';
424
			}
425
		}
426
427
		//NOTE: will just add warnings or do nothing if there's no error
428
		$this->handleStatus( $status, $errorCode );
429
	}
430
431
	/**
432
	 * Checks whether accessing array keys is safe, with e.g. @see DeprecatablePropertyArray
433
	 */
434
	private function isArrayLike( $value ): bool {
435
		return is_array( $value ) || $value instanceof ArrayAccess;
436
	}
437
438
	/**
439
	 * Include messages from a Status object in the API call's output.
440
	 *
441
	 * An ApiErrorHandler is used to report the status, if necessary.
442
	 * If $status->isOK() is false, this method will terminate with an ApiUsageException.
443
	 *
444
	 * @param Status $status The status to report
445
	 * @param string  $errorCode The API error code to use in case $status->isOK() returns false
446
	 *
447
	 * @throws ApiUsageException If $status->isOK() returns false.
448
	 */
449
	private function handleStatus( Status $status, $errorCode ): void {
450
		if ( $status->isGood() ) {
451
			return;
452
		} elseif ( $status->isOK() ) {
453
			$this->errorReporter->reportStatusWarnings( $status );
454
		} else {
455
			$this->errorReporter->reportStatusWarnings( $status );
456
			$this->errorReporter->dieStatus( $status, $errorCode );
457
		}
458
	}
459
460
}
461