1: <?php
2:
3: namespace Charcoal\Model;
4:
5: use \Closure;
6: use \Traversable;
7: use \ArrayIterator;
8: use \CachingIterator;
9: use \LogicException;
10: use \InvalidArgumentException;
11:
12: // Local namespace dependencies
13: use \Charcoal\Model\CollectionInterface;
14: use \Charcoal\Model\ModelInterface;
15:
16: /**
17: * A Model Collection
18: *
19: * For iterating instances of {@see ModelInterface}.
20: *
21: * Used by {@see \Charcoal\Loader\CollectionLoader} for storing results.
22: *
23: * The collection stores models by {@see \Charcoal\Source\StorableInterface their primary key}.
24: * If two objects share the same storable ID but hold disparate data, they are considered
25: * to be alike. Adding an object that shares the same ID as an object previously stored in
26: * the collection will replace the latter.
27: */
28: class Collection implements CollectionInterface
29: {
30: /**
31: * The objects contained in the collection.
32: *
33: * Stored as a dictionary indexed by each object's primary key.
34: * Ensures that each object gets loaded only once by keeping
35: * every loaded object in an associative array.
36: *
37: * @var object[]
38: */
39: protected $objects = [];
40:
41: /**
42: * Create a new collection.
43: *
44: * @param array|Traversable|null $objs Array of objects to pre-populate this collection.
45: * @return void
46: */
47: public function __construct($objs = null)
48: {
49: if ($objs) {
50: $this->merge($objs);
51: }
52: }
53:
54: /**
55: * Retrieve the first object in the collection.
56: *
57: * @return object|null Returns the first object, or NULL if the collection is empty.
58: */
59: public function first()
60: {
61: if (empty($this->objects)) {
62: return null;
63: }
64:
65: return reset($this->objects);
66: }
67:
68: /**
69: * Retrieve the last object in the collection.
70: *
71: * @return object|null Returns the last object, or NULL if the collection is empty.
72: */
73: public function last()
74: {
75: if (empty($this->objects)) {
76: return null;
77: }
78:
79: return end($this->objects);
80: }
81:
82: // Satisfies CollectionInterface
83: // =============================================================================================
84:
85: /**
86: * Merge the collection with the given objects.
87: *
88: * @param array|Traversable $objs Array of objects to append to this collection.
89: * @throws InvalidArgumentException If the given array contains an unacceptable value.
90: * @return self
91: */
92: public function merge($objs)
93: {
94: $objs = $this->asArray($objs);
95:
96: foreach ($objs as $obj) {
97: if (!$this->isAcceptable($obj)) {
98: throw new InvalidArgumentException(
99: sprintf(
100: 'Must be an array of models, contains %s',
101: (is_object($obj) ? get_class($obj) : gettype($obj))
102: )
103: );
104: }
105:
106: $key = $this->modelKey($obj);
107: $this->objects[$key] = $obj;
108: }
109:
110: return $this;
111: }
112:
113: /**
114: * Add an object to the collection.
115: *
116: * @param object $obj An acceptable object.
117: * @throws InvalidArgumentException If the given object is not acceptable.
118: * @return self
119: */
120: public function add($obj)
121: {
122: if (!$this->isAcceptable($obj)) {
123: throw new InvalidArgumentException(
124: sprintf(
125: 'Must be a model, received %s',
126: (is_object($obj) ? get_class($obj) : gettype($obj))
127: )
128: );
129: }
130:
131: $key = $this->modelKey($obj);
132: $this->objects[$key] = $obj;
133:
134: return $this;
135: }
136:
137: /**
138: * Retrieve the object by primary key.
139: *
140: * @param mixed $key The primary key.
141: * @return object|null Returns the requested object or NULL if not in the collection.
142: */
143: public function get($key)
144: {
145: if ($this->isAcceptable($key)) {
146: $key = $this->modelKey($key);
147: }
148:
149: if ($this->has($key)) {
150: return $this->objects[$key];
151: }
152:
153: return null;
154: }
155:
156: /**
157: * Determine if an object exists in the collection by key.
158: *
159: * @param mixed $key The primary key to lookup.
160: * @return boolean
161: */
162: public function has($key)
163: {
164: if ($this->isAcceptable($key)) {
165: $key = $this->modelKey($key);
166: }
167:
168: return array_key_exists($key, $this->objects);
169: }
170:
171: /**
172: * Remove object from collection by primary key.
173: *
174: * @param mixed $key The object primary key to remove.
175: * @throws InvalidArgumentException If the given key is not acceptable.
176: * @return self
177: */
178: public function remove($key)
179: {
180: if ($this->isAcceptable($key)) {
181: $key = $this->modelKey($key);
182: }
183:
184: unset($this->objects[$key]);
185:
186: return $this;
187: }
188:
189: /**
190: * Remove all objects from collection.
191: *
192: * @return self
193: */
194: public function clear()
195: {
196: $this->objects = [];
197:
198: return $this;
199: }
200:
201: /**
202: * Retrieve all objects in collection indexed by primary keys.
203: *
204: * @return object[] An associative array of objects.
205: */
206: public function all()
207: {
208: return $this->objects;
209: }
210:
211: /**
212: * Retrieve all objects in the collection indexed numerically.
213: *
214: * @return object[] A sequential array of objects.
215: */
216: public function values()
217: {
218: return array_values($this->objects);
219: }
220:
221: /**
222: * Retrieve the primary keys of the objects in the collection.
223: *
224: * @return array A sequential array of keys.
225: */
226: public function keys()
227: {
228: return array_keys($this->objects);
229: }
230:
231: // Satisfies ArrayAccess
232: // =============================================================================================
233:
234: /**
235: * Alias of {@see CollectionInterface::has()}.
236: *
237: * @see \ArrayAccess
238: * @param mixed $offset The object primary key or array offset.
239: * @return boolean
240: */
241: public function offsetExists($offset)
242: {
243: if (is_int($offset)) {
244: $offset = $this->resolveOffset($offset);
245: $objects = array_values($this->objects);
246:
247: return array_key_exists($offset, $objects);
248: }
249:
250: return $this->has($offset);
251: }
252:
253: /**
254: * Alias of {@see CollectionInterface::get()}.
255: *
256: * @see \ArrayAccess
257: * @param mixed $offset The object primary key or array offset.
258: * @return mixed Returns the requested object or NULL if not in the collection.
259: */
260: public function offsetGet($offset)
261: {
262: if (is_int($offset)) {
263: $offset = $this->resolveOffset($offset);
264: $objects = array_values($this->objects);
265: if (isset($objects[$offset])) {
266: return $objects[$offset];
267: }
268: }
269:
270: return $this->get($offset);
271: }
272:
273: /**
274: * Alias of {@see CollectionInterface::set()}.
275: *
276: * @see \ArrayAccess
277: * @param mixed $offset The object primary key or array offset.
278: * @param mixed $value The object.
279: * @throws LogicException Attempts to assign an offset.
280: * @return void
281: */
282: public function offsetSet($offset, $value)
283: {
284: if ($offset === null) {
285: $this->add($value);
286: } else {
287: throw new LogicException(
288: sprintf('Offsets are not accepted on the model collection, received %s.', $offset)
289: );
290: }
291: }
292:
293: /**
294: * Alias of {@see CollectionInterface::remove()}.
295: *
296: * @see \ArrayAccess
297: * @param mixed $offset The object primary key or array offset.
298: * @return void
299: */
300: public function offsetUnset($offset)
301: {
302: if (is_int($offset)) {
303: $offset = $this->resolveOffset($offset);
304: $keys = array_keys($this->objects);
305: if (isset($keys[$offset])) {
306: $offset = $keys[$offset];
307: }
308: }
309:
310: $this->remove($offset);
311: }
312:
313: /**
314: * Parse the array offset.
315: *
316: * If offset is non-negative, the sequence will start at that offset in the collection.
317: * If offset is negative, the sequence will start that far from the end of the collection.
318: *
319: * @param integer $offset The array offset.
320: * @return integer Returns the resolved array offset.
321: */
322: protected function resolveOffset($offset)
323: {
324: if (is_int($offset)) {
325: if ($offset < 0) {
326: $offset = ($this->count() - abs($offset));
327: }
328: }
329:
330: return $offset;
331: }
332:
333: // Satisfies Countable
334: // =============================================================================================
335:
336: /**
337: * Get number of objects in collection
338: *
339: * @see \Countable
340: * @return integer
341: */
342: public function count()
343: {
344: return count($this->objects);
345: }
346:
347: // Satisfies IteratorAggregate
348: // =============================================================================================
349:
350: /**
351: * Retrieve an external iterator.
352: *
353: * @see \IteratorAggregate
354: * @return \ArrayIterator
355: */
356: public function getIterator()
357: {
358: return new ArrayIterator($this->objects);
359: }
360:
361: /**
362: * Retrieve a cached iterator.
363: *
364: * @param integer $flags Bitmask of flags.
365: * @return \CachingIterator
366: */
367: public function getCachingIterator($flags = CachingIterator::CALL_TOSTRING)
368: {
369: return new CachingIterator($this->getIterator(), $flags);
370: }
371:
372: // Satisfies backwards-compatibility
373: // =============================================================================================
374:
375: /**
376: * Retrieve the array offset from the given key.
377: *
378: * @deprecated
379: * @param mixed $key The primary key to retrieve the offset from.
380: * @return integer Returns an array offset.
381: */
382: public function pos($key)
383: {
384: trigger_error('Collection::pos() is deprecated', E_USER_DEPRECATED);
385:
386: return array_search($key, array_keys($this->objects));
387: }
388:
389: /**
390: * Alias of {@see self::values()}
391: *
392: * @deprecated
393: * @todo Trigger deprecation error.
394: * @return object[]
395: */
396: public function objects()
397: {
398: return $this->values();
399: }
400:
401: /**
402: * Alias of {@see self::all()}.
403: *
404: * @deprecated
405: * @todo Trigger deprecation error.
406: * @return object[]
407: */
408: public function map()
409: {
410: return $this->all();
411: }
412:
413: // =============================================================================================
414:
415: /**
416: * Determine if the given value is acceptable for the collection.
417: *
418: * Note: Practical for specialized collections extending the base collection.
419: *
420: * @param mixed $value The value being vetted.
421: * @return boolean
422: */
423: public function isAcceptable($value)
424: {
425: return ($value instanceof ModelInterface);
426: }
427:
428: /**
429: * Convert a given object into a model identifier.
430: *
431: * Note: Practical for specialized collections extending the base collection.
432: *
433: * @param object $obj An acceptable object.
434: * @throws InvalidArgumentException If the given object is not acceptable.
435: * @return boolean
436: */
437: protected function modelKey($obj)
438: {
439: if (!$this->isAcceptable($obj)) {
440: throw new InvalidArgumentException(
441: sprintf(
442: 'Must be a model, received %s',
443: (is_object($obj) ? get_class($obj) : gettype($obj))
444: )
445: );
446: }
447:
448: return $obj->id();
449: }
450:
451: /**
452: * Determine if the collection is empty or not.
453: *
454: * @return boolean
455: */
456: public function isEmpty()
457: {
458: return empty($this->objects);
459: }
460:
461: /**
462: * Get a base collection instance from this collection.
463: *
464: * Note: Practical for extended classes.
465: *
466: * @return Collection
467: */
468: public function toBase()
469: {
470: return new self($this);
471: }
472:
473: /**
474: * Parse the given value into an array.
475: *
476: * @link http://php.net/types.array#language.types.array.casting
477: * If an object is converted to an array, the result is an array whose
478: * elements are the object's properties.
479: * @param mixed $value The value being converted.
480: * @return array
481: */
482: protected function asArray($value)
483: {
484: if (is_array($value)) {
485: return $value;
486: } elseif ($value instanceof CollectionInterface) {
487: return $value->all();
488: } elseif ($value instanceof Traversable) {
489: return iterator_to_array($value);
490: } elseif ($value instanceof ModelInterface) {
491: return [ $value ];
492: }
493:
494: return (array)$value;
495: }
496: }
497: