Completed
Pull Request — master (#603)
by no
06:45 queued 03:38
created

ByPropertyIdArray::movePropertyGroup()   C

Complexity

Conditions 9
Paths 49

Size

Total Lines 47
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 30
CRAP Score 9.0027
Metric Value
cc 9
eloc 25
nc 49
nop 2
dl 0
loc 47
ccs 30
cts 31
cp 0.9677
crap 9.0027
rs 5.2941
1
<?php
2
3
namespace Wikibase\DataModel;
4
5
use ArrayObject;
6
use OutOfBoundsException;
7
use RuntimeException;
8
use Wikibase\DataModel\Entity\PropertyId;
9
10
/**
11
 * Helper for managing objects indexed by property id.
12
 *
13
 * This is a light weight alternative approach to using something
14
 * like GenericArrayObject with the advantages that no extra interface
15
 * is needed and that indexing does not happen automatically.
16
 *
17
 * Lack of automatic indexing means that you will need to call the
18
 * buildIndex method before doing any look-ups.
19
 *
20
 * Since no extra interface is used, the user is responsible for only
21
 * adding objects that have a getPropertyId method that returns either
22
 * a string or integer when called with no arguments.
23
 *
24
 * Objects may be added or moved within the structure. Absolute indices (indices according to the
25
 * flat list of objects) may be specified to add or move objects. These management operations take
26
 * the property grouping into account. Adding or moving objects outside their "property groups"
27
 * shifts the whole group towards that index.
28
 *
29
 * Example of moving an object within its "property group":
30
 * o1 (p1)                           o1 (p1)
31
 * o2 (p2)                       /-> o3 (p2)
32
 * o3 (p2) ---> move to index 1 -/   o2 (p2)
33
 *
34
 * Example of moving an object that triggers moving the whole "property group":
35
 * o1 (p1)                       /-> o3 (p2)
36
 * o2 (p2)                       |   o2 (p2)
37
 * o3 (p2) ---> move to index 0 -/   o1 (p1)
38
 *
39
 * @since 0.2
40
 * @deprecated since 5.0, use a DataModel Service instead
41
 *
42
 * @licence GNU GPL v2+
43
 * @author H. Snater < [email protected] >
44
 */
45
class ByPropertyIdArray extends ArrayObject {
46
47
	/**
48
	 * @var array[]|null
49
	 */
50
	private $byId = null;
51
52
	/**
53
	 * @see ArrayObject::__construct
54
	 *
55
	 * @param array|object|null $input
56 65
	 */
57 65
	public function __construct( $input = null ) {
58 65
		parent::__construct( (array)$input );
59
	}
60
61
	/**
62
	 * Builds the index for doing look-ups by property id.
63
	 *
64
	 * @since 0.2
65 62
	 */
66 62
	public function buildIndex() {
67
		$this->byId = array();
68 62
69 61
		foreach ( $this as $object ) {
70
			$propertyId = $object->getPropertyId()->getSerialization();
71 61
72 61
			if ( !array_key_exists( $propertyId, $this->byId ) ) {
73 61
				$this->byId[$propertyId] = array();
74
			}
75 61
76 62
			$this->byId[$propertyId][] = $object;
77 62
		}
78
	}
79
80
	/**
81
	 * Checks whether id indexed array has been generated.
82
	 *
83
	 * @throws RuntimeException
84 64
	 */
85 64
	private function assertIndexIsBuild() {
86 2
		if ( $this->byId === null ) {
87
			throw new RuntimeException( 'Index not build, call buildIndex first' );
88 62
		}
89
	}
90
91
	/**
92
	 * Returns the property ids used for indexing.
93
	 *
94
	 * @since 0.2
95
	 *
96
	 * @return PropertyId[]
97
	 * @throws RuntimeException
98 38
	 */
99 38
	public function getPropertyIds() {
100
		$this->assertIndexIsBuild();
101 37
102 37
		return array_map(
103 37
			function( $serializedPropertyId ) {
104 37
				return new PropertyId( $serializedPropertyId );
105 37
			},
106 37
			array_keys( $this->byId )
107
		);
108
	}
109
110
	/**
111
	 * Returns the objects featuring the provided property id in the index.
112
	 *
113
	 * @since 0.2
114
	 *
115
	 * @param PropertyId $propertyId
116
	 *
117
	 * @throws OutOfBoundsException
118
	 * @throws RuntimeException
119
	 * @return object[]
120 27
	 */
121 27
	public function getByPropertyId( PropertyId $propertyId ) {
122
		$this->assertIndexIsBuild();
123 26
124 1
		if ( !( array_key_exists( $propertyId->getSerialization(), $this->byId ) ) ) {
125
			throw new OutOfBoundsException( "Object with propertyId \"$propertyId\" not found" );
126
		}
127 25
128
		return $this->byId[$propertyId->getSerialization()];
129
	}
130
131
	/**
132
	 * Returns the absolute index of an object or false if the object could not be found.
133
	 * @since 0.5
134
	 *
135
	 * @param object $object
136
	 *
137
	 * @return bool|int
138
	 * @throws RuntimeException
139 47
	 */
140 47
	public function getFlatArrayIndexOfObject( $object ) {
141
		$this->assertIndexIsBuild();
142 47
143 47
		$i = 0;
144 47
		foreach ( $this as $o ) {
145 47
			if ( $o === $object ) {
146
				return $i;
147 40
			}
148 40
			$i++;
149
		}
150
		return false;
151
	}
152
153
	/**
154
	 * Returns the objects in a flat array (using the indexed form for generating the array).
155
	 * @since 0.5
156
	 *
157
	 * @return object[]
158
	 * @throws RuntimeException
159
	 */
160 55
	public function toFlatArray() {
161 55
		$this->assertIndexIsBuild();
162
163 55
		$array = array();
164 55
		foreach ( $this->byId as $objects ) {
0 ignored issues
show
Bug introduced by
The expression $this->byId of type array<integer,array>|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
165 55
			$array = array_merge( $array, $objects );
166 55
		}
167 55
		return $array;
168
	}
169
170
	/**
171
	 * Returns the absolute numeric indices of objects featuring the same property id.
172
	 *
173
	 * @param PropertyId $propertyId
174
	 *
175
	 * @throws RuntimeException
176
	 * @return int[]
177
	 */
178 43
	private function getFlatArrayIndices( PropertyId $propertyId ) {
179 43
		$this->assertIndexIsBuild();
180
181 43
		$propertyIndices = array();
182 43
		$i = 0;
183
184 43
		foreach ( $this->byId as $serializedPropertyId => $objects ) {
0 ignored issues
show
Bug introduced by
The expression $this->byId of type array<integer,array>|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
185 41
			if ( $serializedPropertyId === $propertyId->getSerialization() ) {
186 41
				$propertyIndices = range( $i, $i + count( $objects ) - 1 );
187 41
				break;
188
			} else {
189 29
				$i += count( $objects );
190
			}
191 43
		}
192
193 43
		return $propertyIndices;
194
	}
195
196
	/**
197
	 * Moves an object within its "property group".
198
	 *
199
	 * @param object $object
200
	 * @param int $toIndex Absolute index within a "property group".
201
	 *
202
	 * @throws OutOfBoundsException
203
	 */
204 38
	private function moveObjectInPropertyGroup( $object, $toIndex ) {
205 38
		$currentIndex = $this->getFlatArrayIndexOfObject( $object );
206
207 38
		if ( $toIndex === $currentIndex ) {
208 17
			return;
209
		}
210
211 21
		$propertyId = $object->getPropertyId();
212
213 21
		$numericIndices = $this->getFlatArrayIndices( $propertyId );
214 21
		$lastIndex = $numericIndices[count( $numericIndices ) - 1];
215
216 21
		if ( $toIndex > $lastIndex + 1 || $toIndex < $numericIndices[0] ) {
217
			throw new OutOfBoundsException( 'Object cannot be moved to ' . $toIndex );
218
		}
219
220 21
		if ( $toIndex >= $lastIndex ) {
221 12
			$this->moveObjectToEndOfPropertyGroup( $object );
222 12
		} else {
223 9
			$this->removeObject( $object );
224
225 9
			$propertyGroup = array_combine(
226 9
				$this->getFlatArrayIndices( $propertyId ),
227 9
				$this->getByPropertyId( $propertyId )
228 9
			);
229
230 9
			$insertBefore = $propertyGroup[$toIndex];
231 9
			$this->insertObjectAtIndex( $object, $this->getFlatArrayIndexOfObject( $insertBefore ) );
0 ignored issues
show
Security Bug introduced by
It seems like $this->getFlatArrayIndexOfObject($insertBefore) targeting Wikibase\DataModel\ByPro...latArrayIndexOfObject() can also be of type false; however, Wikibase\DataModel\ByPro...::insertObjectAtIndex() does only seem to accept integer, did you maybe forget to handle an error condition?
Loading history...
232
		}
233 21
	}
234
235
	/**
236
	 * Moves an object to the end of its "property group".
237
	 *
238
	 * @param object $object
239
	 */
240 12
	private function moveObjectToEndOfPropertyGroup( $object ) {
241 12
		$this->removeObject( $object );
242
243
		/** @var PropertyId $propertyId */
244 12
		$propertyId = $object->getPropertyId();
245 12
		$propertyIdSerialization = $propertyId->getSerialization();
246
247 12
		$propertyGroup = in_array( $propertyIdSerialization, $this->getPropertyIds() )
248 12
			? $this->getByPropertyId( $propertyId )
249 12
			: array();
250
251 12
		$propertyGroup[] = $object;
252 12
		$this->byId[$propertyIdSerialization] = $propertyGroup;
253
254 12
		$this->exchangeArray( $this->toFlatArray() );
255 12
	}
256
257
	/**
258
	 * Removes an object from the array structures.
259
	 *
260
	 * @param object $object
261
	 */
262 23
	private function removeObject( $object ) {
263 23
		$flatArray = $this->toFlatArray();
264 23
		$this->exchangeArray( $flatArray );
265 23
		$this->offsetUnset( array_search( $object, $flatArray ) );
266 23
		$this->buildIndex();
267 23
	}
268
269
	/**
270
	 * Inserts an object at a specific index.
271
	 *
272
	 * @param object $object
273
	 * @param int $index Absolute index within the flat list of objects.
274
	 */
275 9
	private function insertObjectAtIndex( $object, $index ) {
276 9
		$flatArray = $this->toFlatArray();
277
278 9
		$this->exchangeArray( array_merge(
279 9
			array_slice( $flatArray, 0, $index ),
280 9
			array( $object ),
281 9
			array_slice( $flatArray, $index )
282 9
		) );
283
284 9
		$this->buildIndex();
285 9
	}
286
287
	/**
288
	 * @param PropertyId $propertyId
289
	 * @param int $toIndex
290
	 */
291 32
	private function movePropertyGroup( PropertyId $propertyId, $toIndex ) {
292 32
		if ( $this->getPropertyGroupIndex( $propertyId ) === $toIndex ) {
293
			return;
294
		}
295
296 32
		$insertBefore = null;
297
298 32
		$oldIndex = $this->getPropertyGroupIndex( $propertyId );
299 32
		$byIdClone = $this->byId;
300
301
		// Remove "property group" to calculate the groups new index:
302 32
		unset( $this->byId[$propertyId->getSerialization()] );
303
304 32
		if ( $toIndex > $oldIndex ) {
305
			// If the group shall be moved towards the bottom, the number of objects within the
306
			// group needs to be subtracted from the absolute toIndex:
307 17
			$toIndex -= count( $byIdClone[$propertyId->getSerialization()] );
308 17
		}
309
310 32
		foreach ( $this->getPropertyIds() as $pId ) {
311
			// Accepting other than the exact index by using <= letting the "property group" "latch"
312
			// in the next slot.
313 32
			if ( $toIndex <= $this->getPropertyGroupIndex( $pId ) ) {
314 24
				$insertBefore = $pId;
315 24
				break;
316
			}
317 32
		}
318
319 32
		$serializedPropertyId = $propertyId->getSerialization();
320 32
		$this->byId = array();
321
322 32
		foreach ( $byIdClone as $serializedPId => $objects ) {
0 ignored issues
show
Bug introduced by
The expression $byIdClone of type array<integer,array>|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
323 32
			$pId = new PropertyId( $serializedPId );
324 32
			if ( $pId->equals( $propertyId ) ) {
325 32
				continue;
326 32
			} elseif ( $pId->equals( $insertBefore ) ) {
327 24
				$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
328 24
			}
329 32
			$this->byId[$serializedPId] = $objects;
330 32
		}
331
332 32
		if ( $insertBefore === null ) {
333 8
			$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
334 8
		}
335
336 32
		$this->exchangeArray( $this->toFlatArray() );
337 32
	}
338
339
	/**
340
	 * Returns the index of a "property group" (the first object in the flat array that features
341
	 * the specified property). Returns false if property id could not be found.
342
	 *
343
	 * @param PropertyId $propertyId
344
	 *
345
	 * @return bool|int
346
	 */
347 32
	private function getPropertyGroupIndex( PropertyId $propertyId ) {
348 32
		$i = 0;
349
350 32
		foreach ( $this->byId as $serializedPropertyId => $objects ) {
0 ignored issues
show
Bug introduced by
The expression $this->byId of type array<integer,array>|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
351 32
			$pId = new PropertyId( $serializedPropertyId );
352 32
			if ( $pId->equals( $propertyId ) ) {
353 32
				return $i;
354
			}
355 30
			$i += count( $objects );
356 30
		}
357
358
		return false;
359
	}
360
361
	/**
362
	 * Moves an existing object to a new index. Specifying an index outside the object's "property
363
	 * group" will move the object to the edge of the "property group" and shift the whole group
364
	 * to achieve the designated index for the object to move.
365
	 * @since 0.5
366
	 *
367
	 * @param object $object
368
	 * @param int $toIndex Absolute index where to move the object to.
369
	 *
370
	 * @throws OutOfBoundsException
371
	 * @throws RuntimeException
372
	 */
373 47
	public function moveObjectToIndex( $object, $toIndex ) {
374 47
		$this->assertIndexIsBuild();
375
376 47
		if ( !in_array( $object, $this->toFlatArray() ) ) {
377 1
			throw new OutOfBoundsException( 'Object not present in array' );
378 46
		} elseif ( $toIndex < 0 || $toIndex > count( $this ) ) {
379 1
			throw new OutOfBoundsException( 'Specified index is out of bounds' );
380 45
		} elseif ( $this->getFlatArrayIndexOfObject( $object ) === $toIndex ) {
381 7
			return;
382
		}
383
384
		// Determine whether to simply reindex the object within its "property group":
385 38
		$propertyIndices = $this->getFlatArrayIndices( $object->getPropertyId() );
386
387 38
		if ( in_array( $toIndex, $propertyIndices ) ) {
388 8
			$this->moveObjectInPropertyGroup( $object, $toIndex );
389 8
		} else {
390 30
			$edgeIndex = ( $toIndex <= $propertyIndices[0] )
391 30
				? $propertyIndices[0]
392 30
				: $propertyIndices[count( $propertyIndices ) - 1];
393
394 30
			$this->moveObjectInPropertyGroup( $object, $edgeIndex );
395 30
			$this->movePropertyGroup( $object->getPropertyId(), $toIndex );
396
		}
397
398 38
		$this->exchangeArray( $this->toFlatArray() );
399 38
	}
400
401
	/**
402
	 * Adds an object at a specific index. If no index is specified, the object will be append to
403
	 * the end of its "property group" or - if no objects featuring the same property exist - to the
404
	 * absolute end of the array.
405
	 * Specifying an index outside a "property group" will place the new object at the specified
406
	 * index with the existing "property group" objects being shifted towards the new object.
407
	 *
408
	 * @since 0.5
409
	 *
410
	 * @param object $object
411
	 * @param int|null $index Absolute index where to place the new object.
412
	 *
413
	 * @throws RuntimeException
414 7
	 */
415 7
	public function addObjectAtIndex( $object, $index = null ) {
416
		$this->assertIndexIsBuild();
417 7
418 7
		$propertyId = $object->getPropertyId();
419
		$validIndices = $this->getFlatArrayIndices( $propertyId );
420 7
421
		if ( count( $this ) === 0 ) {
422 2
			// Array is empty, just append object.
423 7
			$this->append( $object );
424
		} elseif ( empty( $validIndices ) ) {
425
			// No objects featuring that property exist. The object may be inserted at a place
426 2
			// between existing "property groups".
427 2
			$this->append( $object );
428 2
			if ( $index !== null ) {
429 2
				$this->buildIndex();
430 2
				$this->moveObjectToIndex( $object, $index );
431 2
			}
432
		} else {
433
			// Objects featuring the same property as the object which is about to be added already
434 3
			// exist in the array.
435
			$this->addObjectToPropertyGroup( $object, $index );
436
		}
437 7
438 7
		$this->buildIndex();
439
	}
440
441
	/**
442
	 * Adds an object to an existing property group at the specified absolute index.
443
	 *
444
	 * @param object $object
445
	 * @param int|null $index
446
	 *
447
	 * @throws OutOfBoundsException
448 3
	 */
449
	private function addObjectToPropertyGroup( $object, $index = null ) {
450 3
		/** @var PropertyId $propertyId */
451 3
		$propertyId = $object->getPropertyId();
452
		$validIndices = $this->getFlatArrayIndices( $propertyId );
453 3
454
		if ( empty( $validIndices ) ) {
455
			throw new OutOfBoundsException( 'No objects featuring the object\'s property exist' );
456
		}
457
458 3
		// Add index to allow placing object after the last object of the "property group":
459
		$validIndices[] = $validIndices[count( $validIndices ) - 1] + 1;
460 3
461
		if ( $index === null ) {
462 1
			// If index is null, append object to "property group".
463 1
			$index = $validIndices[count( $validIndices ) - 1];
464
		}
465 3
466
		if ( in_array( $index, $validIndices ) ) {
467 1
			// Add object at index within "property group".
468 1
			$this->byId[$propertyId->getSerialization()][] = $object;
469 1
			$this->exchangeArray( $this->toFlatArray() );
470
			$this->moveObjectToIndex( $object, $index );
471 1
472
		} else {
473 2
			// Index is out of the "property group"; The whole group needs to be moved.
474
			$this->movePropertyGroup( $propertyId, $index );
475
476
			// Move new object to the edge of the "property group" to receive its designated
477 2
			// index:
478 2
			if ( $index < $validIndices[0] ) {
479 2
				array_unshift( $this->byId[$propertyId->getSerialization()], $object );
480
			} else {
481
				$this->byId[$propertyId->getSerialization()][] = $object;
482
			}
483
		}
484 3
485 3
		$this->exchangeArray( $this->toFlatArray() );
486
	}
487
488
}
489