lithium\data\Collection

Implements Serializable
abstract class

The Collection class extends the generic lithium\util\Collection class to provide context-specific features for working with sets of data persisted by a backend data store. This is a general abstraction that operates on arbitrary sets of data from either relational or non-relational data stores.

Instances of lithium\data\Collection or any subclass of it may be serialized. This operation however isn't lossless. The documentation of the serialize() method has more information on the limitations.

Subclasses

Source

abstract class Collection extends \lithium\util\Collection implements \Serializable {

	/**
	 * A reference to this object's parent `Document` object.
	 *
	 * @var object
	 */
	protected $_parent = null;

	/**
	 * If this `Collection` instance has a parent document (see `$_parent`), this value indicates
	 * the key name of the parent document that contains it.
	 *
	 * @see lithium\data\Collection::$_parent
	 * @var string
	 */
	protected $_pathKey = null;

	/**
	 * The fully-namespaced class name of the model object to which this entity set is bound. This
	 * is usually the model that executed the query which created this object.
	 *
	 * @var string
	 */
	protected $_model = null;

	/**
	 * A reference to the query object that originated this entity set; usually an instance of
	 * `lithium\data\model\Query`.
	 *
	 * @see lithium\data\model\Query
	 * @var object
	 */
	protected $_query = null;

	/**
	 * A pointer or resource that is used to load entities from the backend data source that
	 * originated this collection.
	 *
	 * @var resource|object
	 */
	protected $_result = null;

	/**
	 * Indicates whether the current position is valid or not. This overrides the default value of
	 * the parent class.
	 *
	 * @var boolean
	 * @see lithium\util\Collection::valid()
	 */
	protected $_valid = true;

	/**
	 * Contains an array of backend-specific statistics generated by the query that produced this
	 * `Collection` object. These stats are accessible via the `stats()` method.
	 *
	 * @see lithium\data\Collection::stats()
	 * @var array
	 */
	protected $_stats = [];

	/**
	 * Set to `true` when the collection has begun iterating.
	 *
	 * @var boolean
	 */
	protected $_started = false;

	/**
	 * Indicates whether this array was part of a document loaded from a data source, or is part of
	 * a new document, or is in newly-added field of an existing document.
	 *
	 * @var boolean
	 */
	protected $_exists = false;

	/**
	 * If the `Collection` has a schema object assigned (rather than loading one from a model), it
	 * will be assigned here.
	 *
	 * @see lithium\data\Schema
	 * @var lithium\data\Schema
	 */
	protected $_schema = null;

	/**
	 * Hold the "data export" handlers where the keys are fully-namespaced class
	 * names, and the values are closures that take an instance of the class as a
	 * parameter, and return an array or scalar value that the instance represents.
	 *
	 * @see lithium\data\Collection::to()
	 * @var array
	 */
	protected $_handlers = [];

	/**
	 * Holds an array of values that should be processed on initialization.
	 *
	 * @var array
	 */
	protected $_autoConfig = [
		'model', 'result', 'query', 'parent', 'stats', 'pathKey', 'exists', 'schema', 'handlers'
	];

	/**
	 * Constructor.
	 *
	 * @see lithium\data\Collection::$_data
	 * @see lithium\data\Collection::$_model
	 * @param array $config Available configuration options are:
	 *        - `'data'` _array_
	 *        - `'model'` _string|null_
	 * @return void
	 */
	public function __construct(array $config = []) {
		$defaults = ['data' => [], 'model' => null];
		parent::__construct($config + $defaults);
	}

	protected function _init() {
		$data = $this->_config['data'];
		parent::_init();
		$this->set($data);
		foreach (['classes', 'model', 'result', 'query'] as $key) {
			unset($this->_config[$key]);
		}
	}

	/**
	 * Configures protected properties of a `Collection` so that it is parented to `$parent`.
	 *
	 * @param object $parent
	 * @param array $config
	 * @return void
	 */
	public function assignTo($parent, array $config = []) {
		foreach ($config as $key => $val) {
			$this->{'_' . $key} = $val;
		}
		$this->_parent =& $parent;
	}

	/**
	 * Returns the model which this particular collection is based off of.
	 *
	 * @return string The fully qualified model class name.
	 */
	public function model() {
		return $this->_model;
	}

	/**
	 * Returns the object's parent `Document` object.
	 *
	 * @return object
	 */
	public function parent() {
		return $this->_parent;
	}

	/**
	 * A flag indicating whether or not the items of this collection exists.
	 *
	 * @return boolean `True` if exists, `false` otherwise.
	 */
	public function exists() {
		return $this->_exists;
	}

	public function schema($field = null) {
		$schema = null;

		switch (true) {
			case ($this->_schema):
				$schema = $this->_schema;
			break;
			case ($model = $this->_model):
				$schema = $model::schema();
			break;
		}
		if ($schema) {
			return $field ? $schema->fields($field) : $schema;
		}
	}

	/**
	 * Allows several items to be assigned at once.
	 *
	 * For example:
	 * ```
	 * $collection->set([
	 *	42 => ['title' => 'Lorem Ipsum'],
	 *	43 => ['title' => 'Dolor Amet']
	 * ]);
	 * ```
	 *
	 * @param arary $values An associative array of fields and values to assign to the `Collection`.
	 * @return void
	 */
	public function set($values) {
		foreach ($values as $key => $val) {
			$this[$key] = $val;
		}
	}

	/**
	 * Returns a boolean indicating whether an offset exists for the
	 * current `Collection`.
	 *
	 * @param string $offset String or integer indicating the offset or
	 *        index of an entity in the set.
	 * @return boolean Result.
	 */
	public function offsetExists($offset) {
		$this->offsetGet($offset);
		return array_key_exists($offset, $this->_data);
	}

	/**
	 * Gets an `Entity` object using PHP's array syntax, i.e. `$documents[3]` or `$records[5]`.
	 *
	 * @param mixed $offset The offset.
	 * @return mixed Returns an `Entity` object if exists otherwise returns `null`.
	 */
	public function offsetGet($offset) {
		while (!array_key_exists($offset, $this->_data) && $this->_populate()) {}

		if (array_key_exists($offset, $this->_data)) {
			return $this->_data[$offset];
		}
		return null;
	}

	/**
	 * Adds the specified object to the `Collection` instance, and assigns associated metadata to
	 * the added object.
	 *
	 * @param string $offset The offset to assign the value to.
	 * @param mixed $data The entity object to add.
	 * @return mixed Returns the set `Entity` object.
	 */
	public function offsetSet($offset, $data) {
		$this->offsetGet($offset);
		return $this->_set($data, $offset);
	}

	/**
	 * Unsets an offset.
	 *
	 * @param integer $offset The offset to unset.
	 */
	public function offsetUnset($offset) {
		$this->offsetGet($offset);
		prev($this->_data);
		if (key($this->_data) === null) {
			$this->rewind();
		}
		unset($this->_data[$offset]);
	}

	/**
	 * Rewinds the collection to the beginning.
	 */
	public function rewind() {
		$this->_started = true;
		reset($this->_data);
		$this->_valid = !empty($this->_data) || $this->_populate() !== null;
		return current($this->_data);
	}

	/**
	 * Returns the currently pointed to record's unique key.
	 *
	 * @param boolean $full If true, returns the complete key.
	 * @return mixed
	 */
	public function key($full = false) {
		if ($this->_started === false) {
			$this->current();
		}
		if ($this->_valid) {
			$key = key($this->_data);
			return (is_array($key) && !$full) ? reset($key) : $key;
		}
		return null;
	}

	/**
	 * Returns the item keys.
	 *
	 * @return array The keys of the items.
	 */
	public function keys() {
		$this->offsetGet(null);
		return parent::keys();
	}

	/**
	 * Returns the currently pointed to record in the set.
	 *
	 * @return object|boolean An instance of `Record` or `false` if there is no current valid one.
	 */
	public function current() {
		if (!$this->_started) {
			$this->rewind();
		}
		if (!$this->_valid) {
			return false;
		}
		return current($this->_data);
	}

	/**
	 * Returns the next document in the set, and advances the object's internal pointer. If the end
	 * of the set is reached, a new document will be fetched from the data source connection handle
	 * If no more documents can be fetched, returns `null`.
	 *
	 * @return mixed Returns the next document in the set, or `false`, if no more documents are
	 *         available.
	 */
	public function next() {
		if (!$this->_started) {
			$this->rewind();
		}
		next($this->_data);
		$this->_valid = key($this->_data) !== null;

		if (!$this->_valid) {
			$this->_valid = $this->_populate() !== null;
		}
		return current($this->_data);
	}

	/**
	 * Checks if current position is valid.
	 *
	 * @return boolean `true` if valid, `false` otherwise.
	 */
	public function valid() {
		if (!$this->_started) {
			$this->rewind();
		}
		return $this->_valid;
	}

	/**
	 * Overrides parent `find()` implementation to enable key/value-based filtering of entity
	 * objects contained in this collection.
	 *
	 * @param mixed $filter Callback to use for filtering, or array of key/value pairs which entity
	 *        properties will be matched against.
	 * @param array $options Options to modify the behavior of this method. See the documentation
	 *        for the `$options` parameter of `lithium\util\Collection::find()`.
	 * @return mixed The filtered items. Will be an array unless `'collect'` is defined in the
	 *         `$options` argument, then an instance of this class will be returned.
	 */
	public function find($filter, array $options = []) {
		$this->offsetGet(null);
		if (is_array($filter)) {
			$filter = $this->_filterFromArray($filter);
		}
		return parent::find($filter, $options);
	}

	/**
	 * Overrides parent `first()` implementation to enable key/value-based filtering.
	 *
	 * @param mixed $filter In addition to a callback (see parent), can also be an array where the
	 *              keys and values must match the property values of the objects being inspected.
	 * @return object Returns the first object found matching the filter criteria.
	 */
	public function first($filter = null) {
		return parent::first(is_array($filter) ? $this->_filterFromArray($filter) : $filter);
	}

	/**
	 * Creates a filter based on an array of key/value pairs that must match the items in a
	 * `Collection`.
	 *
	 * @param array $filter An array of key/value pairs used to filter `Collection` items.
	 * @return \Closure Returns a closure that wraps the array and attempts to match each value
	 *         against `Collection` item properties.
	 */
	protected function _filterFromArray(array $filter) {
		return function($item) use ($filter) {
			foreach ($filter as $key => $val) {
				if ($item->{$key} != $val) {
					return false;
				}
			}
			return true;
		};
	}

	/**
	 * Returns meta information for this `Collection`.
	 *
	 * @return array
	 */
	public function meta() {
		return ['model' => $this->_model];
	}

	/**
	 * Applies a callback to all data in the collection.
	 *
	 * Overridden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @return object This collection instance.
	 */
	public function each($filter) {
		$this->offsetGet(null);
		return parent::each($filter);
	}

	/**
	 * Applies a callback to a copy of all data in the collection
	 * and returns the result.
	 *
	 * Overriden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @param array $options The available options are:
	 *        - `'collect'`: If `true`, the results will be returned wrapped
	 *        in a new `Collection` object or subclass.
	 * @return object The filtered data.
	 */
	public function map($filter, array $options = []) {
		$defaults = ['collect' => true];
		$options += $defaults;

		$this->offsetGet(null);
		$data = parent::map($filter, $options);

		if ($options['collect']) {
			foreach (['_model', '_schema', '_pathKey'] as $key) {
				$data->{$key} = $this->{$key};
			}
		}
		return $data;
	}

	/**
	 * Reduce, or fold, a collection down to a single value
	 *
	 * Overridden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @param mixed $initial Initial value
	 * @return mixed A single reduced value
	 */
	public function reduce($filter, $initial = false) {
		if (!$this->closed()) {
			while ($this->next()) {}
		}
		return parent::reduce($filter, $initial);
	}

	/**
	 * Sorts the objects in the collection, useful in situations where
	 * you are already using the underlying datastore to sort results.
	 *
	 * Overriden to load any data that has not yet been loaded.
	 *
	 * @see lithium\util\Collection::sort()
	 * @param string|callable $field The field to sort the data on, can also be a callback
	 *        to a custom sort function.
	 * @param array $options Reserved for future use.
	 * @return lithium\data\Collection Returns itself.
	 */
	public function sort($field = 'id', array $options = []) {
		$this->offsetGet(null);

		if (is_string($field)) {
			$sorter = function ($a, $b) use ($field) {
				if (is_array($a)) {
					$a = (object) $a;
				}
				if (is_array($b)) {
					$b = (object) $b;
				}
				return strcmp($a->$field, $b->$field);
			};
		} elseif (is_callable($field)) {
			$sorter = $field;
		} else {
			return $this;
		}
		return parent::sort($sorter, $options);
	}

	/**
	 * Converts the current state of the data structure to an array.
	 *
	 * @return array Returns the array value of the data in this `Collection`.
	 */
	public function data() {
		return $this->to('array', ['indexed' => null]);
	}

	/**
	 * Converts a `Collection` object to another type of object, or a simple type such as an array.
	 * The supported values of `$format` depend on the format handlers registered in the static
	 * property `Collection::$_formats`. The `Collection` class comes with built-in support for
	 * array conversion, but other formats may be registered.
	 *
	 * Once the appropriate handlers are registered, a `Collection` instance can be converted into
	 * any handler-supported format, i.e.:
	 * ```
	 * $collection->to('json'); // returns a JSON string
	 * $collection->to('xml'); // returns an XML string
	 * ```
	 *
	 *  _Please note that Lithium does not ship with a default XML handler, but one can be
	 * configured easily._
	 *
	 * @see lithium\util\Collection::toArray()
	 * @see lithium\util\Collection::formats()
	 * @see lithium\util\Collection::$_formats
	 * @param string $format By default the only supported value is `'array'`. However, additional
	 *        format handlers can be registered using the `formats()` method.
	 * @param array $options Options for converting this collection:
	 *        - `'internal'` _boolean_: Indicates whether the current internal representation of the
	 *          collection should be exported. Defaults to `false`, which uses the standard iterator
	 *          interfaces. This is useful for exporting record sets, where records are lazy-loaded,
	 *          and the collection must be iterated in order to fetch all objects.
	 *        - `'indexed'` _boolean|null_: Allows to control how converted data is keyed. When set
	 *          to `true` will force indexed conversion of the collection (the default) even if the
	 *          collection has a parent. When `false` will convert without indexing. Provide `null`
	 *          as a value to this option to only index when the collection has no parent.
	 * @return mixed The object converted to the value specified in `$format`; usually an array or
	 *         string.
	 */
	public function to($format, array $options = []) {
		$defaults = ['internal' => false, 'indexed' => true, 'handlers' => []];
		$options += $defaults;

		$options['handlers'] += $this->_handlers;
		$this->offsetGet(null);

		$index = $options['indexed'] || ($options['indexed'] === null && $this->_parent === null);
		if (!$index) {
			$data = array_values($this->_data);
		} else {
			$data = $options['internal'] ? $this->_data : $this;
		}
		return $this->_to($format, $data, $options);
	}

	/**
	 * Return's the pointer or resource that is used to load entities from the backend
	 * data source that originated this collection. This is useful in many cases for
	 * additional methods related to debugging queries.
	 *
	 * @return object The pointer or resource from the data source
	 */
	public function result() {
		return $this->_result;
	}

	/**
	 * Gets the stat or stats associated with this `Collection`.
	 *
	 * @param string $name Stat name.
	 * @return mixed Single stat if `$name` supplied, else all stats for this
	 *         `Collection`.
	 */
	public function stats($name = null) {
		if ($name) {
			return isset($this->_stats[$name]) ? $this->_stats[$name] : null;
		}
		return $this->_stats;
	}

	/**
	 * Executes when the associated result resource pointer reaches the end of its data set. The
	 * resource is freed by the connection, and the reference to the connection is unlinked.
	 */
	public function close() {
		if (!empty($this->_result)) {
			unset($this->_result);
			$this->_result = null;
		}
	}

	/**
	 * Checks to see if this entity has already fetched all available entities and freed the
	 * associated result resource.
	 *
	 * @return boolean Returns true if all entities are loaded and the database resources have been
	 *         freed, otherwise returns false.
	 */
	public function closed() {
		return empty($this->_result);
	}

	/**
	 * Destructor. Ensures that the data set's connection is closed when the object is destroyed.
	 *
	 * @return void
	 */
	public function __destruct() {
		$this->close();
	}

	/**
	 * Prepares, enables and executes serialization of the object.
	 *
	 * Note: because of the limitations outlined below custom
	 * handlers are ignored with serialized objects.
	 *
	 * Pulls all results to entirely populate `_data` and closes the object
	 * freeing the associated result resource. This allows for skipping
	 * the `_result` property which may hold unserializable `PDOStatement`s.
	 *
	 * Properties that hold anonymous functions are also skipped. Some of these
	 * can almost be reconstructed (`_handlers`).
	 *
	 * @return string Serialized properties of the object.
	 */
	public function serialize() {
		$this->offsetGet(null);
		static::__destruct();

		$vars = get_object_vars($this);
		unset($vars['_result']);
		unset($vars['_handlers']);

		return serialize($vars);
	}

	/**
	 * Prepares, enables and executes unserialization of the object.
	 *
	 * Restores state of the object including pulled results. Tries
	 * to restore `_handlers` by calling into `_init()`.
	 *
	 * @param string $data Serialized properties of the object.
	 * @return void
	 */
	public function unserialize($data) {
		$vars = unserialize($data);
		parent::_init();

		foreach ($vars as $key => $value) {
			$this->{$key} = $value;
		}
	}

	/**
	 * A method to be implemented by concrete `Collection` classes which, provided a reference to a
	 * backend data source, and a resource representing a query result cursor, fetches new result
	 * data and wraps it in the appropriate object type, which is added into the `Collection` and
	 * returned.
	 *
	 * @return mixed Returns the next `Record`, `Document` object or other `Entity` object if
	 *         exists. Returns `null` otherwise.
	 */
	abstract protected function _populate();

	/**
	 * A method to be implemented by concrete `Collection` classes which sets data to a specified
	 * offset and wraps all data array in its appropriate object type.
	 *
	 * @see lithium\data\Collection::_populate()
	 * @see lithium\data\Collection::offsetSet()
	 * @param mixed $data An array or an `Entity` object to set.
	 * @param mixed $offset The offset. If offset is `null` data is simply appended to the set.
	 * @param array $options Any additional options to pass to the `Entity`'s constructor.
	 * @return object Returns the inserted `Record`, `Document` object or other `Entity` object.
	 */
	abstract protected function _set($data = null, $offset = null, $options = []);
}