Completed
Push — master ( 7b50af...c4a39f )
by Jeroen De
11:31 queued 11:02
created

HashArray::removeDuplicates()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 17
ccs 9
cts 9
cp 1
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 8
nc 3
nop 0
crap 3

1 Method

Rating   Name   Duplication   Size   Complexity  
A HashArray::offsetSet() 0 3 1
1
<?php
2
3
namespace Wikibase\DataModel;
4
5
use ArrayObject;
6
use Comparable;
7
use Hashable;
8
use InvalidArgumentException;
9
use Traversable;
10
use Wikibase\DataModel\Internal\MapValueHasher;
11
12
/**
13
 * Generic array object with lookups based on hashes of the elements.
14
 *
15
 * Elements need to implement Hashable.
16
 *
17
 * Note that by default the getHash method uses @see MapValueHashesr
18
 * which returns a hash based on the contents of the list, regardless
19
 * of order and keys.
20
 *
21
 * Also note that if the Hashable elements are mutable, any modifications
22
 * made to them via their mutator methods will not cause an update of
23
 * their associated hash in this array.
24
 *
25
 * When acceptDuplicates is set to true, multiple elements with the same
26
 * hash can reside in the HashArray. Lookup by such a non-unique hash will
27
 * return only the first element and deletion will also delete only
28
 * the first such element.
29
 *
30
 * @since 0.1
31
 *
32
 * @license GPL-2.0+
33
 * @author Jeroen De Dauw < [email protected] >
34
 */
35
abstract class HashArray extends ArrayObject implements Hashable, Comparable {
36
37
	/**
38
	 * Maps element hashes to their offsets.
39
	 *
40
	 * @since 0.1
41
	 *
42
	 * @var array [ element hash (string) => array [ element offset (string|int) ] | element offset (string|int) ]
43
	 */
44
	protected $offsetHashes = [];
45
46
	/**
47
	 * If duplicate values (based on hash) should be accepted or not.
48
	 *
49
	 * @since 0.3
50
	 *
51
	 * @var bool
52
	 */
53
	protected $acceptDuplicates = false;
54
55
	/**
56
	 * @var integer
57
	 */
58
	protected $indexOffset = 0;
59
60
	/**
61
	 * Returns the name of an interface/class that the element should implement/extend.
62
	 *
63
	 * @since 0.4
64
	 *
65
	 * @return string
66
	 */
67
	abstract public function getObjectType();
68
69
	/**
70
	 * @see ArrayObject::__construct
71
	 *
72
	 * @param array|Traversable|null $input
73
	 * @param int $flags
74
	 * @param string $iteratorClass
75
	 *
76
	 * @throws InvalidArgumentException
77
	 */
78
	public function __construct( $input = null, $flags = 0, $iteratorClass = 'ArrayIterator' ) {
79
		parent::__construct( [], $flags, $iteratorClass );
80
81
		if ( $input !== null ) {
82
			if ( !is_array( $input ) && !( $input instanceof Traversable ) ) {
83
				throw new InvalidArgumentException( '$input must be an array or Traversable' );
84
			}
85
86
			foreach ( $input as $offset => $value ) {
87
				$this->offsetSet( $offset, $value );
88
			}
89
		}
90
	}
91
92
	/**
93
	 * Finds a new offset for when appending an element.
94
	 * The base class does this, so it would be better to integrate,
95
	 * but there does not appear to be any way to do this...
96
	 *
97
	 * @return integer
98
	 */
99
	protected function getNewOffset() {
100 8
		while ( $this->offsetExists( $this->indexOffset ) ) {
101 8
			$this->indexOffset++;
102 8
		}
103 8
104
		return $this->indexOffset;
105 8
	}
106
107
	/**
108
	 * Gets called before a new element is added to the ArrayObject.
109
	 *
110
	 * At this point the index is always set (ie not null) and the
111
	 * value is always of the type returned by @see getObjectType.
112
	 *
113
	 * Should return a boolean. When false is returned the element
114
	 * does not get added to the ArrayObject.
115
	 *
116
	 * @since 0.1
117
	 *
118
	 * @param int|string $index
119
	 * @param Hashable $hashable
120
	 *
121
	 * @return bool
122
	 */
123
	protected function preSetElement( $index, $hashable ) {
124 8
		$hash = $hashable->getHash();
125 8
126
		$hasHash = $this->hasElementHash( $hash );
127 8
128
		if ( !$this->acceptDuplicates && $hasHash ) {
129 8
			return false;
130
		}
131
		else {
132
			if ( $hasHash ) {
133 8
				if ( !is_array( $this->offsetHashes[$hash] ) ) {
134 4
					$this->offsetHashes[$hash] = [ $this->offsetHashes[$hash] ];
135 2
				}
136 2
137
				$this->offsetHashes[$hash][] = $index;
138 4
			}
139 4
			else {
140
				$this->offsetHashes[$hash] = $index;
141 4
			}
142
143
			return true;
144 8
		}
145
	}
146
147
	/**
148
	 * Returns if there is an element with the provided hash.
149
	 *
150
	 * @since 0.1
151
	 *
152
	 * @param string $elementHash
153
	 *
154
	 * @return bool
155
	 */
156
	public function hasElementHash( $elementHash ) {
157 21
		return array_key_exists( $elementHash, $this->offsetHashes );
158 21
	}
159
160
	/**
161
	 * Returns if there is an element with the same hash as the provided element in the list.
162
	 *
163
	 * @since 0.1
164
	 *
165
	 * @param Hashable $element
166
	 *
167
	 * @return bool
168
	 */
169
	public function hasElement( Hashable $element ) {
170 12
		return $this->hasElementHash( $element->getHash() );
171 12
	}
172
173
	/**
174
	 * Removes the element with the hash of the provided element, if there is such an element in the list.
175
	 *
176
	 * @since 0.1
177
	 *
178
	 * @param Hashable $element
179
	 */
180
	public function removeElement( Hashable $element ) {
181 8
		$this->removeByElementHash( $element->getHash() );
182 8
	}
183 8
184
	/**
185
	 * Removes the element with the provided hash, if there is such an element in the list.
186
	 *
187
	 * @since 0.1
188
	 *
189
	 * @param string $elementHash
190
	 */
191
	public function removeByElementHash( $elementHash ) {
192 9
		if ( $this->hasElementHash( $elementHash ) ) {
193 9
			$offset = $this->offsetHashes[$elementHash];
194 9
195
			if ( is_array( $offset ) ) {
196 9
				$offset = reset( $offset );
197 3
			}
198 3
199
			$this->offsetUnset( $offset );
200 9
		}
201 9
	}
202 9
203
	/**
204
	 * Adds the provided element to the list if there is no element with the same hash yet.
205
	 *
206
	 * @since 0.1
207
	 *
208
	 * @param Hashable $element
209
	 *
210
	 * @return bool Indicates if the element was added or not.
211
	 */
212
	public function addElement( Hashable $element ) {
213 12
		$append = $this->acceptDuplicates || !$this->hasElementHash( $element->getHash() );
214 12
215
		if ( $append ) {
216 12
			$this->append( $element );
217 8
		}
218 8
219
		return $append;
220 12
	}
221
222
	/**
223
	 * Returns the element with the provided hash or false if there is no such element.
224
	 *
225
	 * @since 0.1
226
	 *
227
	 * @param string $elementHash
228
	 *
229
	 * @return mixed|bool
230
	 */
231
	public function getByElementHash( $elementHash ) {
232
		if ( $this->hasElementHash( $elementHash ) ) {
233
			$offset = $this->offsetHashes[$elementHash];
234
235
			if ( is_array( $offset ) ) {
236
				$offset = reset( $offset );
237
			}
238
239
			return $this->offsetGet( $offset );
240
		}
241
		else {
242
			return false;
243
		}
244
	}
245
246
	/**
247
	 * @see ArrayObject::offsetUnset
248
	 *
249
	 * @since 0.1
250
	 *
251
	 * @param mixed $index
252
	 */
253
	public function offsetUnset( $index ) {
254 13
		if ( $this->offsetExists( $index ) ) {
255 13
			/**
256
			 * @var Hashable $element
257
			 */
258
			$element = $this->offsetGet( $index );
259 13
260
			$hash = $element->getHash();
261 13
262
			if ( array_key_exists( $hash, $this->offsetHashes )
263 13
				&& is_array( $this->offsetHashes[$hash] )
264 13
				&& count( $this->offsetHashes[$hash] ) > 1 ) {
265 13
				$this->offsetHashes[$hash] = array_filter(
266 3
					$this->offsetHashes[$hash],
267 3
					function( $value ) use ( $index ) {
268 3
						return $value !== $index;
269 3
					}
270
				);
271 3
			}
272 3
			else {
273
				unset( $this->offsetHashes[$hash] );
274 12
			}
275
276
			parent::offsetUnset( $index );
277 13
		}
278 13
	}
279 13
280
	/**
281
	 * @see Hashable::getHash
282
	 *
283
	 * The hash is purely valuer based. Order of the elements in the array is not held into account.
284
	 *
285
	 * @since 0.1
286
	 *
287
	 * @return string
288
	 */
289
	public function getHash() {
290 4
		$hasher = new MapValueHasher();
291 4
		return $hasher->hash( $this );
292 4
	}
293
294
	/**
295
	 * @see Comparable::equals
296
	 *
297
	 * The comparison is done purely value based, ignoring the order of the elements in the array.
298
	 *
299
	 * @since 0.3
300
	 *
301
	 * @param mixed $target
302
	 *
303
	 * @return bool
304
	 */
305
	public function equals( $target ) {
306 4
		if ( $this === $target ) {
307 4
			return true;
308 4
		}
309
310
		return $target instanceof self
311
			&& $this->getHash() === $target->getHash();
312 4
	}
313
314
	/**
315
	 * @see ArrayObject::append
316
	 *
317
	 * @param mixed $value
318
	 */
319
	public function append( $value ) {
320 12
		$this->setElement( null, $value );
321 12
	}
322
323
	/**
324
	 * @see ArrayObject::offsetSet()
325
	 *
326 12
	 * @param mixed $index
327 12
	 * @param mixed $value
328
	 */
329 12
	public function offsetSet( $index, $value ) {
330 3
		$this->setElement( $index, $value );
331 3
	}
332
333 12
	/**
334
	 * Returns if the provided value has the same type as the elements
335 12
	 * that can be added to this ArrayObject.
336 12
	 *
337
	 * @param mixed $value
338
	 *
339
	 * @return bool
340
	 */
341
	protected function hasValidType( $value ) {
342
		$class = $this->getObjectType();
343
		return $value instanceof $class;
344
	}
345
346
	/**
347
	 * Method that actually sets the element and holds
348
	 * all common code needed for set operations, including
349
	 * type checking and offset resolving.
350 4
	 *
351 4
	 * If you want to do additional indexing or have code that
352 4
	 * otherwise needs to be executed whenever an element is added,
353
	 * you can overload @see preSetElement.
354 4
	 *
355
	 * @param mixed $index
356 4
	 * @param mixed $value
357 4
	 *
358
	 * @throws InvalidArgumentException
359 4
	 */
360 4
	protected function setElement( $index, $value ) {
361
		if ( !$this->hasValidType( $value ) ) {
362 4
			$type = is_object( $value ) ? get_class( $value ) : gettype( $value );
363
364
			throw new InvalidArgumentException( '$value must be an instance of ' . $this->getObjectType() . '; got ' . $type );
365
		}
366
367
		if ( $index === null ) {
368
			$index = $this->getNewOffset();
369
		}
370 4
371 4
		if ( $this->preSetElement( $index, $value ) ) {
372
			parent::offsetSet( $index, $value );
0 ignored issues
show
Comprehensibility Bug introduced by
It seems like you call parent on a different method (offsetSet() instead of setElement()). Are you sure this is correct? If so, you might want to change this to $this->offsetSet().

This check looks for a call to a parent method whose name is different than the method from which it is called.

Consider the following code:

class Daddy
{
    protected function getFirstName()
    {
        return "Eidur";
    }

    protected function getSurName()
    {
        return "Gudjohnsen";
    }
}

class Son
{
    public function getFirstName()
    {
        return parent::getSurname();
    }
}

The getFirstName() method in the Son calls the wrong method in the parent class.

Loading history...
373 4
		}
374
	}
375 4
376 4
	/**
377 4
	 * @see Serializable::serialize
378 4
	 *
379 4
	 * @return string
380
	 */
381
	public function serialize() {
382
		return serialize( [
383
			'data' => $this->getArrayCopy(),
384
			'index' => $this->indexOffset,
385
		] );
386 8
	}
387 8
388 8
	/**
389
	 * @see Serializable::unserialize
390
	 *
391
	 * @param string $serialized
392
	 */
393
	public function unserialize( $serialized ) {
394
		$serializationData = unserialize( $serialized );
395
396 4
		foreach ( $serializationData['data'] as $offset => $value ) {
397 4
			// Just set the element, bypassing checks and offset resolving,
398 4
			// as these elements have already gone through this.
399
			parent::offsetSet( $offset, $value );
0 ignored issues
show
Comprehensibility Bug introduced by
It seems like you call parent on a different method (offsetSet() instead of unserialize()). Are you sure this is correct? If so, you might want to change this to $this->offsetSet().

This check looks for a call to a parent method whose name is different than the method from which it is called.

Consider the following code:

class Daddy
{
    protected function getFirstName()
    {
        return "Eidur";
    }

    protected function getSurName()
    {
        return "Gudjohnsen";
    }
}

class Son
{
    public function getFirstName()
    {
        return parent::getSurname();
    }
}

The getFirstName() method in the Son calls the wrong method in the parent class.

Loading history...
400
		}
401
402
		$this->indexOffset = $serializationData['index'];
403
	}
404
405
	/**
406
	 * Returns if the ArrayObject has no elements.
407
	 *
408 8
	 * @return bool
409 8
	 */
410 8
	public function isEmpty() {
411
		return !$this->getIterator()->valid();
412
	}
413
414
}
415