ByPropertyIdArray::insertObjectAtIndex()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1.008

Importance

Changes 0
Metric Value
dl 0
loc 11
ccs 4
cts 5
cp 0.8
rs 9.9
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 1.008
1
<?php
2
3
namespace Wikibase\DataModel;
4
5
use ArrayObject;
6
use InvalidArgumentException;
7
use OutOfBoundsException;
8
use RuntimeException;
9
use Traversable;
10
use Wikibase\DataModel\Entity\PropertyId;
11
12
/**
13
 * Helper for managing objects indexed by property id.
14
 *
15
 * This is a light weight alternative approach to using something
16
 * like GenericArrayObject with the advantages that no extra interface
17
 * is needed and that indexing does not happen automatically.
18
 *
19
 * Lack of automatic indexing means that you will need to call the
20
 * buildIndex method before doing any look-ups.
21
 *
22
 * Since no extra interface is used, the user is responsible for only
23
 * adding objects that have a getPropertyId method that returns either
24
 * a string or integer when called with no arguments.
25
 *
26
 * Objects may be added or moved within the structure. Absolute indices (indices according to the
27
 * flat list of objects) may be specified to add or move objects. These management operations take
28
 * the property grouping into account. Adding or moving objects outside their "property groups"
29
 * shifts the whole group towards that index.
30
 *
31
 * Example of moving an object within its "property group":
32
 * o1 (p1)                           o1 (p1)
33
 * o2 (p2)                       /-> o3 (p2)
34
 * o3 (p2) ---> move to index 1 -/   o2 (p2)
35
 *
36
 * Example of moving an object that triggers moving the whole "property group":
37
 * o1 (p1)                       /-> o3 (p2)
38
 * o2 (p2)                       |   o2 (p2)
39
 * o3 (p2) ---> move to index 0 -/   o1 (p1)
40
 *
41
 * @since 0.2
42
 * @deprecated since 5.0, use a DataModel Service instead
43
 *
44
 * @license GPL-2.0-or-later
45
 * @author H. Snater < [email protected] >
46
 */
47
class ByPropertyIdArray extends ArrayObject {
48
49
	/**
50
	 * @var array[]|null
51
	 */
52
	private $byId = null;
53
54
	/**
55
	 * @deprecated since 5.0, use a DataModel Service instead
56 65
	 * @see ArrayObject::__construct
57 65
	 *
58 65
	 * @param PropertyIdProvider[]|Traversable|null $input
59
	 *
60
	 * @throws InvalidArgumentException
61
	 */
62
	public function __construct( $input = null ) {
63
		if ( is_object( $input ) && !( $input instanceof Traversable ) ) {
64
			throw new InvalidArgumentException( '$input must be an array, Traversable or null' );
65 62
		}
66 62
67
		parent::__construct( (array)$input );
68 62
	}
69 61
70
	/**
71 61
	 * Builds the index for doing look-ups by property id.
72 61
	 *
73 61
	 * @since 0.2
74
	 */
75 61
	public function buildIndex() {
76 62
		$this->byId = [];
77 62
78
		/** @var PropertyIdProvider $object */
79
		foreach ( $this as $object ) {
80
			$propertyId = $object->getPropertyId()->getSerialization();
81
82
			if ( !array_key_exists( $propertyId, $this->byId ) ) {
83
				$this->byId[$propertyId] = [];
84 64
			}
85 64
86 2
			$this->byId[$propertyId][] = $object;
87
		}
88 62
	}
89
90
	/**
91
	 * Checks whether id indexed array has been generated.
92
	 *
93
	 * @throws RuntimeException
94
	 */
95
	private function assertIndexIsBuild() {
96
		if ( $this->byId === null ) {
97
			throw new RuntimeException( 'Index not build, call buildIndex first' );
98 38
		}
99 38
	}
100
101 37
	/**
102 37
	 * Returns the property ids used for indexing.
103 37
	 *
104 37
	 * @since 0.2
105 37
	 *
106 37
	 * @return PropertyId[]
107
	 * @throws RuntimeException
108
	 */
109
	public function getPropertyIds() {
110
		$this->assertIndexIsBuild();
111
112
		return array_map(
113
			function( $serializedPropertyId ) {
114
				return new PropertyId( $serializedPropertyId );
115
			},
116
			array_keys( $this->byId )
117
		);
118
	}
119
120 27
	/**
121 27
	 * Returns the objects featuring the provided property id in the index.
122
	 *
123 26
	 * @since 0.2
124 1
	 *
125
	 * @param PropertyId $propertyId
126
	 *
127 25
	 * @throws OutOfBoundsException
128
	 * @throws RuntimeException
129
	 * @return PropertyIdProvider[]
130
	 */
131
	public function getByPropertyId( PropertyId $propertyId ) {
132
		$this->assertIndexIsBuild();
133
134
		if ( !( array_key_exists( $propertyId->getSerialization(), $this->byId ) ) ) {
135
			throw new OutOfBoundsException( "Object with propertyId \"$propertyId\" not found" );
136
		}
137
138
		return $this->byId[$propertyId->getSerialization()];
139 47
	}
140 47
141
	/**
142 47
	 * Returns the absolute index of an object or false if the object could not be found.
143 47
	 * @since 0.5
144 47
	 *
145 47
	 * @param PropertyIdProvider $object
146
	 *
147 40
	 * @return bool|int
148 40
	 * @throws RuntimeException
149
	 */
150
	public function getFlatArrayIndexOfObject( $object ) {
151
		$this->assertIndexIsBuild();
152
153
		$i = 0;
154
		foreach ( $this as $o ) {
155
			if ( $o === $object ) {
156
				return $i;
157
			}
158
			$i++;
159
		}
160 55
		return false;
161 55
	}
162
163 55
	/**
164 55
	 * Returns the objects in a flat array (using the indexed form for generating the array).
165 55
	 * @since 0.5
166 55
	 *
167 55
	 * @return PropertyIdProvider[]
168
	 * @throws RuntimeException
169
	 */
170
	public function toFlatArray() {
171
		$this->assertIndexIsBuild();
172
173
		$array = [];
174
		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...
175
			$array = array_merge( $array, $objects );
176
		}
177
		return $array;
178 43
	}
179 43
180
	/**
181 43
	 * Returns the absolute numeric indices of objects featuring the same property id.
182 43
	 *
183
	 * @param PropertyId $propertyId
184 43
	 *
185 41
	 * @throws RuntimeException
186 41
	 * @return int[]
187 41
	 */
188
	private function getFlatArrayIndices( PropertyId $propertyId ) {
189 29
		$this->assertIndexIsBuild();
190
191 43
		$propertyIndices = [];
192
		$i = 0;
193 43
194
		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...
195
			if ( $serializedPropertyId === $propertyId->getSerialization() ) {
196
				$propertyIndices = range( $i, $i + count( $objects ) - 1 );
197
				break;
198
			} else {
199
				$i += count( $objects );
200
			}
201
		}
202
203
		return $propertyIndices;
204 38
	}
205 38
206
	/**
207 38
	 * Moves an object within its "property group".
208 17
	 *
209
	 * @param PropertyIdProvider $object
210
	 * @param int $toIndex Absolute index within a "property group".
211 21
	 *
212
	 * @throws OutOfBoundsException
213 21
	 */
214 21
	private function moveObjectInPropertyGroup( $object, $toIndex ) {
215
		$currentIndex = $this->getFlatArrayIndexOfObject( $object );
216 21
217
		if ( $toIndex === $currentIndex ) {
218
			return;
219
		}
220 21
221 12
		$propertyId = $object->getPropertyId();
222 12
223 9
		$numericIndices = $this->getFlatArrayIndices( $propertyId );
224
		$lastIndex = end( $numericIndices );
225 9
226 9
		if ( $toIndex > $lastIndex + 1 || $toIndex < $numericIndices[0] ) {
227 9
			throw new OutOfBoundsException( 'Object cannot be moved to ' . $toIndex );
228 9
		}
229
230 9
		if ( $toIndex >= $lastIndex ) {
231 9
			$this->moveObjectToEndOfPropertyGroup( $object );
232
		} else {
233 21
			$this->removeObject( $object );
234
235
			$propertyGroup = array_combine(
236
				$this->getFlatArrayIndices( $propertyId ),
237
				$this->getByPropertyId( $propertyId )
238
			);
239
240 12
			$insertBefore = $propertyGroup[$toIndex];
241 12
			$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...
242
		}
243
	}
244 12
245 12
	/**
246
	 * Moves an object to the end of its "property group".
247 12
	 *
248 12
	 * @param PropertyIdProvider $object
249 12
	 */
250
	private function moveObjectToEndOfPropertyGroup( $object ) {
251 12
		$this->removeObject( $object );
252 12
253
		$propertyId = $object->getPropertyId();
254 12
		$propertyIdSerialization = $propertyId->getSerialization();
255 12
256
		$propertyGroup = in_array( $propertyIdSerialization, $this->getPropertyIds() )
257
			? $this->getByPropertyId( $propertyId )
258
			: [];
259
260
		$propertyGroup[] = $object;
261
		$this->byId[$propertyIdSerialization] = $propertyGroup;
262 23
263 23
		$this->exchangeArray( $this->toFlatArray() );
264 23
	}
265 23
266 23
	/**
267 23
	 * Removes an object from the array structures.
268
	 *
269
	 * @param PropertyIdProvider $object
270
	 */
271
	private function removeObject( $object ) {
272
		$flatArray = $this->toFlatArray();
273
		$this->exchangeArray( $flatArray );
274
		$this->offsetUnset( array_search( $object, $flatArray ) );
275 9
		$this->buildIndex();
276 9
	}
277
278 9
	/**
279 9
	 * Inserts an object at a specific index.
280 9
	 *
281 9
	 * @param PropertyIdProvider $object
282 9
	 * @param int $index Absolute index within the flat list of objects.
283
	 */
284 9
	private function insertObjectAtIndex( $object, $index ) {
285 9
		$flatArray = $this->toFlatArray();
286
287
		$this->exchangeArray( array_merge(
288
			array_slice( $flatArray, 0, $index ),
289
			[ $object ],
290
			array_slice( $flatArray, $index )
291 32
		) );
292 32
293
		$this->buildIndex();
294
	}
295
296 32
	/**
297
	 * @param PropertyId $propertyId
298 32
	 * @param int $toIndex
299 32
	 */
300
	private function movePropertyGroup( PropertyId $propertyId, $toIndex ) {
301
		if ( $this->getPropertyGroupIndex( $propertyId ) === $toIndex ) {
302 32
			return;
303
		}
304 32
305
		$insertBefore = null;
306
307 17
		$oldIndex = $this->getPropertyGroupIndex( $propertyId );
308 17
		$byIdClone = $this->byId;
309
310 32
		// Remove "property group" to calculate the groups new index:
311
		unset( $this->byId[$propertyId->getSerialization()] );
312
313 32
		if ( $toIndex > $oldIndex ) {
314 24
			// If the group shall be moved towards the bottom, the number of objects within the
315 24
			// group needs to be subtracted from the absolute toIndex:
316
			$toIndex -= count( $byIdClone[$propertyId->getSerialization()] );
317 32
		}
318
319 32
		foreach ( $this->getPropertyIds() as $pId ) {
320 32
			// Accepting other than the exact index by using <= letting the "property group" "latch"
321
			// in the next slot.
322 32
			if ( $toIndex <= $this->getPropertyGroupIndex( $pId ) ) {
323 32
				$insertBefore = $pId;
324 32
				break;
325 32
			}
326 32
		}
327 24
328 24
		$serializedPropertyId = $propertyId->getSerialization();
329 32
		$this->byId = [];
330 32
331
		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...
332 32
			$pId = new PropertyId( $serializedPId );
333 8
			if ( $pId->equals( $propertyId ) ) {
334 8
				continue;
335
			} elseif ( $pId->equals( $insertBefore ) ) {
336 32
				$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
337 32
			}
338
			$this->byId[$serializedPId] = $objects;
339
		}
340
341
		if ( $insertBefore === null ) {
342
			$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
343
		}
344
345
		$this->exchangeArray( $this->toFlatArray() );
346
	}
347 32
348 32
	/**
349
	 * Returns the index of a "property group" (the first object in the flat array that features
350 32
	 * the specified property). Returns false if property id could not be found.
351 32
	 *
352 32
	 * @param PropertyId $propertyId
353 32
	 *
354
	 * @return bool|int
355 30
	 */
356 30
	private function getPropertyGroupIndex( PropertyId $propertyId ) {
357
		$i = 0;
358
359
		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...
360
			$pId = new PropertyId( $serializedPropertyId );
361
			if ( $pId->equals( $propertyId ) ) {
362
				return $i;
363
			}
364
			$i += count( $objects );
365
		}
366
367
		return false;
368
	}
369
370
	/**
371
	 * Moves an existing object to a new index. Specifying an index outside the object's "property
372
	 * group" will move the object to the edge of the "property group" and shift the whole group
373 47
	 * to achieve the designated index for the object to move.
374 47
	 * @since 0.5
375
	 *
376 47
	 * @param PropertyIdProvider $object
377 1
	 * @param int $toIndex Absolute index where to move the object to.
378 46
	 *
379 1
	 * @throws OutOfBoundsException
380 45
	 * @throws RuntimeException
381 7
	 */
382
	public function moveObjectToIndex( $object, $toIndex ) {
383
		$this->assertIndexIsBuild();
384
385 38
		if ( !in_array( $object, $this->toFlatArray() ) ) {
386
			throw new OutOfBoundsException( 'Object not present in array' );
387 38
		} elseif ( $toIndex < 0 || $toIndex > count( $this ) ) {
388 8
			throw new OutOfBoundsException( 'Specified index is out of bounds' );
389 8
		} elseif ( $this->getFlatArrayIndexOfObject( $object ) === $toIndex ) {
390 30
			return;
391 30
		}
392 30
393
		// Determine whether to simply reindex the object within its "property group":
394 30
		$propertyIndices = $this->getFlatArrayIndices( $object->getPropertyId() );
395 30
396
		if ( in_array( $toIndex, $propertyIndices ) ) {
397
			$this->moveObjectInPropertyGroup( $object, $toIndex );
398 38
		} else {
399 38
			$edgeIndex = ( $toIndex <= $propertyIndices[0] )
400
				? $propertyIndices[0]
401
				: end( $propertyIndices );
402
403
			$this->moveObjectInPropertyGroup( $object, $edgeIndex );
0 ignored issues
show
Security Bug introduced by
It seems like $edgeIndex defined by $toIndex <= $propertyInd...: end($propertyIndices) on line 399 can also be of type false; however, Wikibase\DataModel\ByPro...ObjectInPropertyGroup() does only seem to accept integer, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
404
			$this->movePropertyGroup( $object->getPropertyId(), $toIndex );
405
		}
406
407
		$this->exchangeArray( $this->toFlatArray() );
408
	}
409
410
	/**
411
	 * Adds an object at a specific index. If no index is specified, the object will be append to
412
	 * the end of its "property group" or - if no objects featuring the same property exist - to the
413
	 * absolute end of the array.
414 7
	 * Specifying an index outside a "property group" will place the new object at the specified
415 7
	 * index with the existing "property group" objects being shifted towards the new object.
416
	 *
417 7
	 * @since 0.5
418 7
	 *
419
	 * @param PropertyIdProvider $object
420 7
	 * @param int|null $index Absolute index where to place the new object.
421
	 *
422 2
	 * @throws OutOfBoundsException
423 7
	 * @throws RuntimeException
424
	 */
425
	public function addObjectAtIndex( $object, $index = null ) {
426 2
		$this->assertIndexIsBuild();
427 2
428 2
		$propertyId = $object->getPropertyId();
429 2
		$validIndices = $this->getFlatArrayIndices( $propertyId );
430 2
431 2
		if ( count( $this ) === 0 ) {
432
			// Array is empty, just append object.
433
			$this->append( $object );
434 3
		} elseif ( empty( $validIndices ) ) {
435
			// No objects featuring that property exist. The object may be inserted at a place
436
			// between existing "property groups".
437 7
			$this->append( $object );
438 7
			if ( $index !== null ) {
439
				$this->buildIndex();
440
				$this->moveObjectToIndex( $object, $index );
441
			}
442
		} else {
443
			// Objects featuring the same property as the object which is about to be added already
444
			// exist in the array.
445
			$this->addObjectToPropertyGroup( $object, $index );
446
		}
447
448 3
		$this->buildIndex();
449
	}
450 3
451 3
	/**
452
	 * Adds an object to an existing property group at the specified absolute index.
453 3
	 *
454
	 * @param PropertyIdProvider $object
455
	 * @param int|null $index
456
	 *
457
	 * @throws OutOfBoundsException
458 3
	 */
459
	private function addObjectToPropertyGroup( $object, $index = null ) {
460 3
		$propertyId = $object->getPropertyId();
461
		$validIndices = $this->getFlatArrayIndices( $propertyId );
462 1
463 1
		if ( empty( $validIndices ) ) {
464
			throw new OutOfBoundsException( 'No objects featuring the object\'s property exist' );
465 3
		}
466
467 1
		// Add index to allow placing object after the last object of the "property group":
468 1
		$validIndices[] = end( $validIndices ) + 1;
469 1
470
		if ( $index === null ) {
471 1
			// If index is null, append object to "property group".
472
			$index = end( $validIndices );
473 2
		}
474
475
		if ( in_array( $index, $validIndices ) ) {
476
			// Add object at index within "property group".
477 2
			$this->byId[$propertyId->getSerialization()][] = $object;
478 2
			$this->exchangeArray( $this->toFlatArray() );
479 2
			$this->moveObjectToIndex( $object, $index );
0 ignored issues
show
Bug introduced by
It seems like $index can also be of type double or false; however, Wikibase\DataModel\ByPro...ay::moveObjectToIndex() does only seem to accept integer, 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...
480
481
		} else {
482
			// Index is out of the "property group"; The whole group needs to be moved.
483
			$this->movePropertyGroup( $propertyId, $index );
0 ignored issues
show
Bug introduced by
It seems like $index can also be of type double or false; however, Wikibase\DataModel\ByPro...ay::movePropertyGroup() does only seem to accept integer, 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...
484 3
485 3
			// Move new object to the edge of the "property group" to receive its designated
486
			// index:
487
			if ( $index < $validIndices[0] ) {
488
				array_unshift( $this->byId[$propertyId->getSerialization()], $object );
489
			} else {
490
				$this->byId[$propertyId->getSerialization()][] = $object;
491
			}
492
		}
493
494
		$this->exchangeArray( $this->toFlatArray() );
495
	}
496
497
}
498