Passed
Push — int32EntityId ( fa80fb )
by no
05:13
created

ByPropertyIdArray::getPropertyGroupIndex()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

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