ChangeOpsMerge   A
last analyzed

Complexity

Total Complexity 37

Size/Duplication

Total Lines 303
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 20

Importance

Changes 0
Metric Value
wmc 37
lcom 2
cbo 20
dl 0
loc 303
rs 9.44
c 0
b 0
f 0

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 22 1
A assertValidIgnoreConflictValues() 0 7 2
A getFingerprintChangeOpFactory() 0 3 1
A getSiteLinkChangeOpFactory() 0 3 1
A apply() 0 20 1
A generateChangeOps() 0 6 1
A generateLabelsChangeOps() 0 13 4
A generateDescriptionsChangeOps() 0 12 5
A generateAliasesChangeOps() 0 6 2
A generateSitelinksChangeOps() 0 10 3
A generateSitelinksChangeOpsWithNoConflict() 0 11 1
A generateSitelinksChangeOpsWithConflict() 0 24 4
A getSite() 0 7 2
A applyConstraintChecks() 0 15 2
A removeConflictsWithEntity() 0 15 4
A checkStatementLinks() 0 15 3
1
<?php
2
3
namespace Wikibase\Repo\ChangeOp;
4
5
use InvalidArgumentException;
6
use Site;
7
use SiteLookup;
8
use ValueValidators\Error;
9
use ValueValidators\Result;
10
use Wikibase\DataModel\Entity\EntityId;
11
use Wikibase\DataModel\Entity\Item;
12
use Wikibase\DataModel\Entity\ItemId;
13
use Wikibase\DataModel\SiteLink;
14
use Wikibase\Repo\Merge\StatementsMerger;
15
use Wikibase\Repo\Merge\Validator\NoCrossReferencingStatements;
16
use Wikibase\Repo\Validators\CompositeEntityValidator;
17
use Wikibase\Repo\Validators\EntityConstraintProvider;
18
use Wikibase\Repo\Validators\UniquenessViolation;
19
20
/**
21
 * @license GPL-2.0-or-later
22
 * @author Addshore
23
 * @author Daniel Kinzler
24
 */
25
class ChangeOpsMerge {
26
27
	/**
28
	 * @var Item
29
	 */
30
	private $fromItem;
31
32
	/**
33
	 * @var Item
34
	 */
35
	private $toItem;
36
37
	/**
38
	 * @var ChangeOps
39
	 */
40
	private $fromChangeOps;
41
42
	/**
43
	 * @var ChangeOps
44
	 */
45
	private $toChangeOps;
46
47
	/**
48
	 * @var string[]
49
	 */
50
	private $ignoreConflicts;
51
52
	/**
53
	 * @var EntityConstraintProvider
54
	 */
55
	private $constraintProvider;
56
57
	/**
58
	 * @var ChangeOpFactoryProvider
59
	 */
60
	private $changeOpFactoryProvider;
61
62
	/**
63
	 * @var SiteLookup
64
	 */
65
	private $siteLookup;
66
67
	public static $conflictTypes = [ 'description', 'sitelink', 'statement' ];
68
69
	/**
70
	 * @var StatementsMerger
71
	 */
72
	private $statementsMerger;
73
74
	/**
75
	 * @param Item $fromItem
76
	 * @param Item $toItem
77
	 * @param string[] $ignoreConflicts list of elements to ignore conflicts for
78
	 *        can only contain 'description' and or 'sitelink' and or 'statement'
79
	 * @param EntityConstraintProvider $constraintProvider
80
	 * @param ChangeOpFactoryProvider $changeOpFactoryProvider
81
	 * @param SiteLookup $siteLookup
82
	 * @param StatementsMerger $statementsMerger
83
	 *
84
	 * @todo Injecting ChangeOpFactoryProvider is an Abomination Unto Nuggan, we'll
85
	 *        need a MergeChangeOpsSequenceBuilder or some such. This will allow us
86
	 *        to merge different kinds of entities nicely, too.
87
	 */
88
	public function __construct(
89
		Item $fromItem,
90
		Item $toItem,
91
		array $ignoreConflicts,
92
		EntityConstraintProvider $constraintProvider,
93
		ChangeOpFactoryProvider $changeOpFactoryProvider,
94
		SiteLookup $siteLookup,
95
		StatementsMerger $statementsMerger
96
	) {
97
		$this->assertValidIgnoreConflictValues( $ignoreConflicts );
98
99
		$this->fromItem = $fromItem;
100
		$this->toItem = $toItem;
101
		$this->fromChangeOps = new ChangeOps();
102
		$this->toChangeOps = new ChangeOps();
103
		$this->ignoreConflicts = $ignoreConflicts;
104
		$this->constraintProvider = $constraintProvider;
105
		$this->siteLookup = $siteLookup;
106
107
		$this->changeOpFactoryProvider = $changeOpFactoryProvider;
108
		$this->statementsMerger = $statementsMerger;
109
	}
110
111
	/**
112
	 * @param string[] $ignoreConflicts can contain strings 'description' or 'sitelink'
113
	 *
114
	 * @throws InvalidArgumentException
115
	 */
116
	private function assertValidIgnoreConflictValues( array $ignoreConflicts ) {
117
		if ( array_diff( $ignoreConflicts, self::$conflictTypes ) ) {
118
			throw new InvalidArgumentException(
119
				'$ignoreConflicts array can only contain "description" and or "sitelink" and or "statement" values'
120
			);
121
		}
122
	}
123
124
	/**
125
	 * @return FingerprintChangeOpFactory
126
	 */
127
	private function getFingerprintChangeOpFactory() {
128
		return $this->changeOpFactoryProvider->getFingerprintChangeOpFactory();
129
	}
130
131
	/**
132
	 * @return SiteLinkChangeOpFactory
133
	 */
134
	private function getSiteLinkChangeOpFactory() {
135
		return $this->changeOpFactoryProvider->getSiteLinkChangeOpFactory();
136
	}
137
138
	/**
139
	 * @throws ChangeOpException
140
	 */
141
	public function apply() {
142
		// NOTE: we don't want to validate the ChangeOps individually, since they represent
143
		// data already present and saved on the system. Also, validating each would be
144
		// potentially expensive.
145
146
		$this->generateChangeOps();
147
148
		$this->fromChangeOps->apply( $this->fromItem );
149
		$this->toChangeOps->apply( $this->toItem );
150
151
		$this->checkStatementLinks();
152
		$this->statementsMerger->merge( $this->fromItem, $this->toItem );
153
154
		//NOTE: we apply constraint checks on the modified items, but no
155
		//      validation of individual change ops, since we are merging
156
		//      two valid items.
157
		$this->applyConstraintChecks( $this->toItem, $this->fromItem->getId() );
0 ignored issues
show
Bug introduced by
It seems like $this->fromItem->getId() can be null; however, applyConstraintChecks() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
158
159
		return new DummyChangeOpResult();
160
	}
161
162
	private function generateChangeOps() {
163
		$this->generateLabelsChangeOps();
164
		$this->generateDescriptionsChangeOps();
165
		$this->generateAliasesChangeOps();
166
		$this->generateSitelinksChangeOps();
167
	}
168
169
	private function generateLabelsChangeOps() {
170
		foreach ( $this->fromItem->getLabels()->toTextArray() as $langCode => $label ) {
171
			if ( !$this->toItem->getLabels()->hasTermForLanguage( $langCode )
172
				|| $this->toItem->getLabels()->getByLanguage( $langCode )->getText() === $label
173
			) {
174
				$this->fromChangeOps->add( $this->getFingerprintChangeOpFactory()->newRemoveLabelOp( $langCode ) );
175
				$this->toChangeOps->add( $this->getFingerprintChangeOpFactory()->newSetLabelOp( $langCode, $label ) );
176
			} else {
177
				$this->fromChangeOps->add( $this->getFingerprintChangeOpFactory()->newRemoveLabelOp( $langCode ) );
178
				$this->toChangeOps->add( $this->getFingerprintChangeOpFactory()->newAddAliasesOp( $langCode, [ $label ] ) );
179
			}
180
		}
181
	}
182
183
	private function generateDescriptionsChangeOps() {
184
		foreach ( $this->fromItem->getDescriptions()->toTextArray() as $langCode => $desc ) {
185
			if ( !$this->toItem->getDescriptions()->hasTermForLanguage( $langCode )
186
				|| $this->toItem->getDescriptions()->getByLanguage( $langCode )->getText() === $desc
187
			) {
188
				$this->fromChangeOps->add( $this->getFingerprintChangeOpFactory()->newRemoveDescriptionOp( $langCode ) );
189
				$this->toChangeOps->add( $this->getFingerprintChangeOpFactory()->newSetDescriptionOp( $langCode, $desc ) );
190
			} elseif ( !in_array( 'description', $this->ignoreConflicts ) ) {
191
				throw new ChangeOpException( "Conflicting descriptions for language {$langCode}" );
192
			}
193
		}
194
	}
195
196
	private function generateAliasesChangeOps() {
197
		foreach ( $this->fromItem->getAliasGroups()->toTextArray() as $langCode => $aliases ) {
198
			$this->fromChangeOps->add( $this->getFingerprintChangeOpFactory()->newRemoveAliasesOp( $langCode, $aliases ) );
199
			$this->toChangeOps->add( $this->getFingerprintChangeOpFactory()->newAddAliasesOp( $langCode, $aliases ) );
200
		}
201
	}
202
203
	private function generateSitelinksChangeOps() {
204
		foreach ( $this->fromItem->getSiteLinkList()->toArray() as $fromSiteLink ) {
205
			$siteId = $fromSiteLink->getSiteId();
206
			if ( !$this->toItem->getSiteLinkList()->hasLinkWithSiteId( $siteId ) ) {
207
				$this->generateSitelinksChangeOpsWithNoConflict( $fromSiteLink );
208
			} else {
209
				$this->generateSitelinksChangeOpsWithConflict( $fromSiteLink );
210
			}
211
		}
212
	}
213
214
	private function generateSitelinksChangeOpsWithNoConflict( SiteLink $fromSiteLink ) {
215
		$siteId = $fromSiteLink->getSiteId();
216
		$this->fromChangeOps->add( $this->getSiteLinkChangeOpFactory()->newRemoveSiteLinkOp( $siteId ) );
217
		$this->toChangeOps->add(
218
			$this->getSiteLinkChangeOpFactory()->newSetSiteLinkOp(
219
				$siteId,
220
				$fromSiteLink->getPageName(),
221
				$fromSiteLink->getBadges()
222
			)
223
		);
224
	}
225
226
	private function generateSitelinksChangeOpsWithConflict( SiteLink $fromSiteLink ) {
227
		$siteId = $fromSiteLink->getSiteId();
228
		$toSiteLink = $this->toItem->getSiteLink( $siteId );
229
		$fromPageName = $fromSiteLink->getPageName();
230
		$toPageName = $toSiteLink->getPageName();
231
232
		if ( $fromPageName !== $toPageName ) {
233
			$site = $this->getSite( $siteId );
234
			$fromPageName = $site->normalizePageName( $fromPageName );
235
			$toPageName = $site->normalizePageName( $toPageName );
236
		}
237
		if ( $fromPageName === $toPageName ) {
238
			$this->fromChangeOps->add( $this->getSiteLinkChangeOpFactory()->newRemoveSiteLinkOp( $siteId ) );
239
			$this->toChangeOps->add(
240
				$this->getSiteLinkChangeOpFactory()->newSetSiteLinkOp(
241
					$siteId,
242
					$fromPageName,
243
					array_unique( array_merge( $fromSiteLink->getBadges(), $toSiteLink->getBadges() ) )
244
				)
245
			);
246
		} elseif ( !in_array( 'sitelink', $this->ignoreConflicts ) ) {
247
			throw new ChangeOpException( "Conflicting sitelinks for {$siteId}" );
248
		}
249
	}
250
251
	/**
252
	 * @param string $siteId
253
	 *
254
	 * @throws ChangeOpException
255
	 * @return Site
256
	 */
257
	private function getSite( $siteId ) {
258
		$site = $this->siteLookup->getSite( $siteId );
259
		if ( $site === null ) {
260
			throw new ChangeOpException( "Conflicting sitelinks for {$siteId}, Failed to normalize" );
261
		}
262
		return $site;
263
	}
264
265
	/**
266
	 * @param Item $item
267
	 * @param ItemId $fromId
268
	 *
269
	 * @throws ChangeOpValidationException if it would not be possible to save the updated items.
270
	 */
271
	private function applyConstraintChecks( Item $item, ItemId $fromId ) {
272
		$constraintValidator = new CompositeEntityValidator(
273
			$this->constraintProvider->getUpdateValidators( $item->getType() )
274
		);
275
276
		$result = $constraintValidator->validateEntity( $item );
277
		$errors = $result->getErrors();
278
279
		$errors = $this->removeConflictsWithEntity( $errors, $fromId );
280
281
		if ( !empty( $errors ) ) {
282
			$result = Result::newError( $errors );
283
			throw new ChangeOpValidationException( $result );
284
		}
285
	}
286
287
	/**
288
	 * Strip any conflicts with the given $fromId from the array of Error objects
289
	 *
290
	 * @param Error[] $errors
291
	 * @param EntityId $fromId
292
	 *
293
	 * @return Error[]
294
	 */
295
	private function removeConflictsWithEntity( array $errors, EntityId $fromId ) {
296
		$filtered = [];
297
298
		foreach ( $errors as $error ) {
299
			if ( $error instanceof UniquenessViolation
300
				&& $fromId->equals( $error->getConflictingEntity() )
301
			) {
302
				continue;
303
			}
304
305
			$filtered[] = $error;
306
		}
307
308
		return $filtered;
309
	}
310
311
	private function checkStatementLinks() {
312
		if ( in_array( 'statement', $this->ignoreConflicts ) ) {
313
			return;
314
		}
315
316
		$validator = new NoCrossReferencingStatements();
317
		if ( $validator->validate( $this->fromItem, $this->toItem ) ) {
318
			return;
319
		}
320
321
		throw new ChangeOpException(
322
			'The two items cannot be merged because one of them links to the other using the properties: ' .
323
			implode( ', ', $validator->getViolations() )
324
		);
325
	}
326
327
}
328