Passed
Push — entityIdInjection ( 21ce29...71be9d )
by no
05:51
created

ByPropertyIdArray::movePropertyGroup()   C

Complexity

Conditions 9
Paths 49

Size

Total Lines 47
Code Lines 25

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 47
rs 5.2941
cc 9
eloc 25
nc 49
nop 2
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
	 */
57
	public function __construct( $input = null ) {
58
		parent::__construct( (array)$input );
59
	}
60
61
	/**
62
	 * Builds the index for doing look-ups by property id.
63
	 *
64
	 * @since 0.2
65
	 */
66
	public function buildIndex() {
67
		$this->byId = array();
68
69
		foreach ( $this as $object ) {
70
			$propertyId = $object->getPropertyId()->getSerialization();
71
72
			if ( !array_key_exists( $propertyId, $this->byId ) ) {
73
				$this->byId[$propertyId] = array();
74
			}
75
76
			$this->byId[$propertyId][] = $object;
77
		}
78
	}
79
80
	/**
81
	 * Checks whether id indexed array has been generated.
82
	 *
83
	 * @throws RuntimeException
84
	 */
85
	private function assertIndexIsBuild() {
86
		if ( $this->byId === null ) {
87
			throw new RuntimeException( 'Index not build, call buildIndex first' );
88
		}
89
	}
90
91
	/**
92
	 * Returns the property ids used for indexing.
93
	 *
94
	 * @since 0.2
95
	 *
96
	 * @return PropertyId[]
97
	 * @throws RuntimeException
98
	 */
99
	public function getPropertyIds() {
100
		$this->assertIndexIsBuild();
101
102
		return array_map(
103
			function( $serializedPropertyId ) {
104
				return new PropertyId( $serializedPropertyId );
105
			},
106
			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
	 */
121
	public function getByPropertyId( PropertyId $propertyId ) {
122
		$this->assertIndexIsBuild();
123
124
		if ( !( array_key_exists( $propertyId->getSerialization(), $this->byId ) ) ) {
125
			throw new OutOfBoundsException( "Object with propertyId \"$propertyId\" not found" );
126
		}
127
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
	 */
140
	public function getFlatArrayIndexOfObject( $object ) {
141
		$this->assertIndexIsBuild();
142
143
		$i = 0;
144
		foreach ( $this as $o ) {
145
			if ( $o === $object ) {
146
				return $i;
147
			}
148
			$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
	public function toFlatArray() {
161
		$this->assertIndexIsBuild();
162
163
		$array = array();
164
		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
			$array = array_merge( $array, $objects );
166
		}
167
		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
	private function getFlatArrayIndices( PropertyId $propertyId ) {
179
		$this->assertIndexIsBuild();
180
181
		$propertyIndices = array();
182
		$i = 0;
183
184
		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
			if ( $serializedPropertyId === $propertyId->getSerialization() ) {
186
				$propertyIndices = range( $i, $i + count( $objects ) - 1 );
187
				break;
188
			} else {
189
				$i += count( $objects );
190
			}
191
		}
192
193
		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
	private function moveObjectInPropertyGroup( $object, $toIndex ) {
205
		$currentIndex = $this->getFlatArrayIndexOfObject( $object );
206
207
		if ( $toIndex === $currentIndex ) {
208
			return;
209
		}
210
211
		$propertyId = $object->getPropertyId();
212
213
		$numericIndices = $this->getFlatArrayIndices( $propertyId );
214
		$lastIndex = $numericIndices[count( $numericIndices ) - 1];
215
216
		if ( $toIndex > $lastIndex + 1 || $toIndex < $numericIndices[0] ) {
217
			throw new OutOfBoundsException( 'Object cannot be moved to ' . $toIndex );
218
		}
219
220
		if ( $toIndex >= $lastIndex ) {
221
			$this->moveObjectToEndOfPropertyGroup( $object );
222
		} else {
223
			$this->removeObject( $object );
224
225
			$propertyGroup = array_combine(
226
				$this->getFlatArrayIndices( $propertyId ),
227
				$this->getByPropertyId( $propertyId )
228
			);
229
230
			$insertBefore = $propertyGroup[$toIndex];
231
			$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
	}
234
235
	/**
236
	 * Moves an object to the end of its "property group".
237
	 *
238
	 * @param object $object
239
	 */
240
	private function moveObjectToEndOfPropertyGroup( $object ) {
241
		$this->removeObject( $object );
242
243
		/** @var PropertyId $propertyId */
244
		$propertyId = $object->getPropertyId();
245
		$propertyIdSerialization = $propertyId->getSerialization();
246
247
		$propertyGroup = in_array( $propertyIdSerialization, $this->getPropertyIds() )
248
			? $this->getByPropertyId( $propertyId )
249
			: array();
250
251
		$propertyGroup[] = $object;
252
		$this->byId[$propertyIdSerialization] = $propertyGroup;
253
254
		$this->exchangeArray( $this->toFlatArray() );
255
	}
256
257
	/**
258
	 * Removes an object from the array structures.
259
	 *
260
	 * @param object $object
261
	 */
262
	private function removeObject( $object ) {
263
		$flatArray = $this->toFlatArray();
264
		$this->exchangeArray( $flatArray );
265
		$this->offsetUnset( array_search( $object, $flatArray ) );
266
		$this->buildIndex();
267
	}
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
	private function insertObjectAtIndex( $object, $index ) {
276
		$flatArray = $this->toFlatArray();
277
278
		$this->exchangeArray( array_merge(
279
			array_slice( $flatArray, 0, $index ),
280
			array( $object ),
281
			array_slice( $flatArray, $index )
282
		) );
283
284
		$this->buildIndex();
285
	}
286
287
	/**
288
	 * @param PropertyId $propertyId
289
	 * @param int $toIndex
290
	 */
291
	private function movePropertyGroup( PropertyId $propertyId, $toIndex ) {
292
		if ( $this->getPropertyGroupIndex( $propertyId ) === $toIndex ) {
293
			return;
294
		}
295
296
		$insertBefore = null;
297
298
		$oldIndex = $this->getPropertyGroupIndex( $propertyId );
299
		$byIdClone = $this->byId;
300
301
		// Remove "property group" to calculate the groups new index:
302
		unset( $this->byId[$propertyId->getSerialization()] );
303
304
		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
			$toIndex -= count( $byIdClone[$propertyId->getSerialization()] );
308
		}
309
310
		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
			if ( $toIndex <= $this->getPropertyGroupIndex( $pId ) ) {
314
				$insertBefore = $pId;
315
				break;
316
			}
317
		}
318
319
		$serializedPropertyId = $propertyId->getSerialization();
320
		$this->byId = array();
321
322
		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
			$pId = new PropertyId( $serializedPId );
324
			if ( $pId->equals( $propertyId ) ) {
325
				continue;
326
			} elseif ( $pId->equals( $insertBefore ) ) {
327
				$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
328
			}
329
			$this->byId[$serializedPId] = $objects;
330
		}
331
332
		if ( $insertBefore === null ) {
333
			$this->byId[$serializedPropertyId] = $byIdClone[$serializedPropertyId];
334
		}
335
336
		$this->exchangeArray( $this->toFlatArray() );
337
	}
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
	private function getPropertyGroupIndex( PropertyId $propertyId ) {
348
		$i = 0;
349
350
		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
			$pId = new PropertyId( $serializedPropertyId );
352
			if ( $pId->equals( $propertyId ) ) {
353
				return $i;
354
			}
355
			$i += count( $objects );
356
		}
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
	public function moveObjectToIndex( $object, $toIndex ) {
374
		$this->assertIndexIsBuild();
375
376
		if ( !in_array( $object, $this->toFlatArray() ) ) {
377
			throw new OutOfBoundsException( 'Object not present in array' );
378
		} elseif ( $toIndex < 0 || $toIndex > count( $this ) ) {
379
			throw new OutOfBoundsException( 'Specified index is out of bounds' );
380
		} elseif ( $this->getFlatArrayIndexOfObject( $object ) === $toIndex ) {
381
			return;
382
		}
383
384
		// Determine whether to simply reindex the object within its "property group":
385
		$propertyIndices = $this->getFlatArrayIndices( $object->getPropertyId() );
386
387
		if ( in_array( $toIndex, $propertyIndices ) ) {
388
			$this->moveObjectInPropertyGroup( $object, $toIndex );
389
		} else {
390
			$edgeIndex = ( $toIndex <= $propertyIndices[0] )
391
				? $propertyIndices[0]
392
				: $propertyIndices[count( $propertyIndices ) - 1];
393
394
			$this->moveObjectInPropertyGroup( $object, $edgeIndex );
395
			$this->movePropertyGroup( $object->getPropertyId(), $toIndex );
396
		}
397
398
		$this->exchangeArray( $this->toFlatArray() );
399
	}
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
	 */
415
	public function addObjectAtIndex( $object, $index = null ) {
416
		$this->assertIndexIsBuild();
417
418
		$propertyId = $object->getPropertyId();
419
		$validIndices = $this->getFlatArrayIndices( $propertyId );
420
421
		if ( count( $this ) === 0 ) {
422
			// Array is empty, just append object.
423
			$this->append( $object );
424
		} elseif ( empty( $validIndices ) ) {
425
			// No objects featuring that property exist. The object may be inserted at a place
426
			// between existing "property groups".
427
			$this->append( $object );
428
			if ( $index !== null ) {
429
				$this->buildIndex();
430
				$this->moveObjectToIndex( $object, $index );
431
			}
432
		} else {
433
			// Objects featuring the same property as the object which is about to be added already
434
			// exist in the array.
435
			$this->addObjectToPropertyGroup( $object, $index );
436
		}
437
438
		$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
	 */
449
	private function addObjectToPropertyGroup( $object, $index = null ) {
450
		/** @var PropertyId $propertyId */
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