- <?php
- /**
- * Provides a common container for a set of models.
- *
- * Advantage of extending ArrayIterator is that php built-in
- * array-walk functions reset(), next(), key(), current()
- * can be replaced by class-implemented counterparts
- * and vice versa. In other words, if $iterator is an instance
- * of P4\Model\Iterator class then $iterator->next()
- * and next($iterator) are equivalent and same for all other pairs.
- *
- * @copyright 2011 Perforce Software. All rights reserved.
- * @license Please see LICENSE.txt in top-level folder of this distribution.
- * @version <release>/<patch>
- */
-
- namespace P4\Model\Fielded;
-
- use P4\Model\Connected\Iterator as ConnectedIterator;
-
- class Iterator extends ConnectedIterator
- {
- const SORT_ASCENDING = 'ASC';
- const SORT_DESCENDING = 'DESC';
- const SORT_ALPHA = 'ALPHA';
- const SORT_NUMERIC = 'NUMERIC';
- const SORT_NATURAL = 'NATURAL';
- const SORT_NO_CASE = 'NO_CASE';
- const SORT_FIXED = 'FIXED';
-
- const FILTER_NO_CASE = 'NO_CASE';
- const FILTER_CONTAINS = 'CONTAINS';
- const FILTER_STARTS_WITH = 'STARTS_WITH';
- const FILTER_REGEX = 'REGEX';
- const FILTER_MATCH_ALL = 'MATCH_ALL';
- const FILTER_IMPLODE = 'IMPLODE';
-
- /**
- * Define the type of models we want to accept in this iterator.
- */
- protected $allowedModelClass = 'P4\Model\Fielded\FieldedInterface';
-
- /**
- * Get the iterator data as an array.
- * Calls toArray() on all of the models unless 'shallow' is true.
- *
- * @param bool $shallow optional - set shallow to true to avoid calling toArray()
- * on each of the models - defaults to false.
- * @return array all model data as an array.
- */
- public function toArray($shallow = false)
- {
- if ($shallow) {
- return $this->getArrayCopy();
- }
-
- $data = array();
- foreach ($this->getArrayCopy() as $key => $model) {
- $data[$key] = $model->toArray();
- }
- return $data;
- }
-
- /**
- * Reorder models by the given field(s).
- *
- * Multiple fields can be specified to produce a nested sort.
- * Comparison behavior defaults to alphabetical, ascending order.
- * Use the options argument to produce a different order.
- *
- * When sorting on multiple fields, separate options can be given
- * for each field by setting the entry key to the field name and
- * the value to the array of sort options to use for that field.
- *
- * Alternatively, each entry in fields may be an array with two
- * parts where the first part is the field name and the second is
- * the array of sort options to use for that field (can be used to
- * sort on the same field twice with different options).
- *
- * Valid sorting options are:
- *
- * SORT_ASCENDING - default direction
- * SORT_DESCENDING - reverse direction
- * SORT_ALPHA - default alphabetic order comparison
- * SORT_NUMERIC - perform numeric comparison
- * SORT_NATURAL - perform natural order comparison
- * SORT_NO_CASE - perform case-insensitive comparison
- * SORT_FIXED - put entries in a prescribed order
- * e.g. SORT_FIXED => array(val, val, ...)
- *
- * @param array|string $fields one or more fields to order by
- * if multiple fields are specified,
- * performs a nested sort.
- * @param array $options optional - one or more sorting options
- * @return Iterator provides fluent interface.
- */
- public function sortBy($fields, $options = array())
- {
- if (!is_array($options)) {
- throw new \InvalidArgumentException(
- "Cannot sort. Sort options must be an array."
- );
- }
-
- // normalize fields to an array.
- $fields = (array) $fields;
-
- // determine comparison function to use for each field.
- $comparators = array();
- foreach ($fields as $key => $value) {
-
- // three ways to specify fields + options:
- // - common case: key is an integer and value is a string,
- // takes value as field name and second func. param as options.
- // - if key is a string and value is an array,
- // takes key as field name and value as options.
- // - if key is an integer and value is an array with two parts,
- // takes first part as field name and second part as options.
- if (is_integer($key) && is_string($value)) {
- $comparators[] = array($value, $this->getSortComparator($options));
- } elseif (is_string($key) && is_array($value)) {
- $comparators[] = array($key, $this->getSortComparator($value));
- } elseif (is_integer($key) && is_array($value) && count($value) == 2) {
- $comparators[] = array($value[0], $this->getSortComparator($value[1]));
- } else {
- throw new \InvalidArgumentException("Cannot sort. Invalid sort field(s) given.");
- }
-
- }
-
- // perform sort.
- // uses '@' to silence warnings about array being modified by
- // comparison function - can occur due to lazy loading.
- @$this->uasort(
- function ($a, $b) use ($comparators) {
- foreach ($comparators as $comparator) {
- $result = call_user_func(
- $comparator[1],
- Iterator::implodeValue($a->get($comparator[0])),
- Iterator::implodeValue($b->get($comparator[0]))
- );
-
- // if values are equal, compare the next field.
- if (!$result) {
- continue;
- }
-
- return $result;
- }
-
- return 0;
- }
- );
-
- return $this;
- }
-
- /**
- * Reorder models using a callback function for the comparison.
- * Effectively just a wrapper for the uasort() method.
- *
- * @param callable $callback the function to pass to uasort().
- * @return Iterator provides fluent interface.
- */
- public function sortByCallback($callback)
- {
- if (!is_callable($callback)) {
- throw new \InvalidArgumentException(
- "Cannot sort iterator. Given callback is not callable."
- );
- }
-
- // perform sort.
- // uses '@' to silence warnings about array being modified by
- // comparison function - can occur due to lazy loading.
- @$this->uasort($callback);
-
- return $this;
- }
-
- /**
- * Implodes arrays into comma-separated strings, returns non-arrays unmodified.
- *
- * @param mixed $value A value to be imploded; only arrays will be modified.
- * @return mixed An imploded array, or unmodified value.
- */
- public static function implodeValue($value)
- {
- return is_array($value) ? implode(', ', $value) : $value;
- }
-
- /**
- * Filter items of this instance.
- *
- * You may specify one or more fields to check for one or more acceptable
- * values. Models that do not have acceptable values will be removed from
- * the iterator.
- *
- * Valid filter options are:
- *
- * FILTER_NO_CASE - perform case insensitive comparisons
- * FILTER_CONTAINS - fields only need to contain a value to match
- * FILTER_STARTS_WITH - fields only need to start with a value to match
- * FILTER_REGEX - value is a regular expression
- * FILTER_INVERSE - inverse filtering behavior - items that match are removed
- * FILTER_MATCH_ALL - require all values to match at least once per model
- * FILTER_COPY - return a filtered copy without modifying original
- * FILTER_IMPLODE - fields that contain arrays will be flattened prior to matching
- *
- * @param string|array $fields one or more fields to check for acceptable values.
- * @param string|array $values one or more acceptable values/patterns
- * @param string|array $options optional - one or more filtering options
- * @return Iterator provides fluent interface
- */
- public function filter($fields, $values, $options = array())
- {
- // normalize arguments to arrays.
- $fields = is_null($fields) ? $fields : (array) $fields;
- $values = (array) $values;
- $options = (array) $options;
- $copy = new static;
-
- // remove items that don't pass the filter.
- foreach ($this->getArrayCopy() as $key => $model) {
- $passesFilter = $this->passesFilter($model, $fields, $values, $options);
-
- // inverse behavior if FILTER_INVERSE option is set
- if (in_array(static::FILTER_INVERSE, $options, true)) {
- $passesFilter = !$passesFilter;
- }
-
- if (!$passesFilter && !in_array(static::FILTER_COPY, $options, true)) {
- $this->offsetUnset($key);
- } elseif ($passesFilter && in_array(static::FILTER_COPY, $options, true)) {
- $copy[$key] = $model;
- }
- }
-
- return in_array(static::FILTER_COPY, $options, true) ? $copy : $this;
- }
-
- /**
- * Search (filters) this iterator instance by user-provided query.
- *
- * Splits the given query string on whitespace and comma,
- * then filters the iterator with the following options:
- *
- * FILTER_NO_CASE - perform case insensitive comparisons
- * FILTER_CONTAINS - fields only need to contain a value to match
- * FILTER_IMPLODE - fields that contain arrays will be flattened prior to matching
- * FILTER_MATCH_ALL - require all values to match at least once per model
- *
- * The options can be overridden via the optional $options param.
- *
- * @param array|string $fields the fields to match on
- * @param string $query the user-supplied search string
- * @param array $options optional - flags to pass to the filter.
- * @return Iterator provides fluent interface.
- */
- public function search($fields, $query, array $options = null)
- {
- // normalize fields to array.
- $fields = (array) $fields;
-
- // split query into words.
- $query = preg_split('/[\s,]+/', trim($query));
-
- // use default options if none provided.
- $options = $options !== null ? $options : array(
- Iterator::FILTER_CONTAINS,
- Iterator::FILTER_NO_CASE,
- Iterator::FILTER_IMPLODE,
- Iterator::FILTER_MATCH_ALL
- );
-
- // remove models that don't match search query.
- return $this->filter($fields, $query, $options);
- }
-
- /**
- * Check if model passes the given filter criteria.
- *
- * @param P4\ModelInterface $model the model to test against filter
- * @param string|array $fields one or more fields to check for
- * acceptable values
- * @param string|array $values one or more acceptable values/patterns
- * @param string|array $options optional - one or more filtering options
- * @return bool true if the model passes filter; false otherwise
- */
- protected function passesFilter($model, $fields, $values, $options)
- {
- $fields = is_array($fields)
- ? array_intersect($fields, $model->getFields())
- : $model->getFields();
-
- $matches = array();
- $matchAll = in_array(static::FILTER_MATCH_ALL, $options, true);
-
- foreach ($fields as $field) {
- $value = $model->get($field);
- foreach ($values as $filter) {
- if ($this->valueMatches($value, $filter, $options)) {
- $matches[$filter] = true;
-
- // exit if we have satisfied match.
- if (!$matchAll || count($matches) == count($values)) {
- return true;
- }
- }
- }
- }
-
- return false;
- }
-
- /**
- * Evaluate if value matches given filter with respect to options.
- *
- * @param string $value the value to test against filter/pattern
- * @param string $filter the filter/pattern to match against
- * @param array $options filter options
- * @return bool true if the value matches the filter.
- */
- protected function valueMatches($value, $filter, $options)
- {
- // array comparisons require FILTER_IMPLODE so we can convert to a string
- if (is_array($value) && in_array(static::FILTER_IMPLODE, $options, true)) {
- $value = static::implodeValue($value);
- }
-
- // evaluate matching against null
- if (is_null($filter)) {
- return is_null($value);
- }
-
- // evaluate only string, numeric, and boolean values
- if (!is_string($value) && !is_numeric($value) && !is_bool($value)) {
- return false;
- }
-
- $noCase = in_array(static::FILTER_NO_CASE, $options, true);
-
- // perform 'contains' comparison.
- if (in_array(static::FILTER_CONTAINS, $options, true)) {
- return false !== ($noCase
- ? stripos($value, $filter)
- : strpos($value, $filter));
- }
-
- // perform 'starts with' comparison.
- if (in_array(static::FILTER_STARTS_WITH, $options, true)) {
- return 0 === ($noCase
- ? stripos($value, $filter)
- : strpos($value, $filter));
- }
-
- // perform 'regex' comparison.
- if (in_array(static::FILTER_REGEX, $options, true)) {
- // make pattern case insensitive if no-case set.
- if ($noCase) {
- $filter .= 'i';
- }
-
- return preg_match($filter, $value);
- }
-
- // default literal/exact comparison.
- return 0 === ($noCase
- ? strcasecmp($value, $filter)
- : strcmp($value, $filter));
- }
-
- /**
- * Return the appropriate comparison function to use
- * for the given sort options.
- *
- * @param array $options sort options @see sortBy()
- * @return mixed a callable comparison function.
- */
- protected function getSortComparator($options)
- {
- // ensure options are in an expected format.
- $options = $this->normalizeSortOptions($options);
-
- // select the comparison function to use based on flags given.
- if ($options[static::SORT_FIXED]) {
- $order = array_flip((array) $options[static::SORT_FIXED]);
- $comparator = function ($a, $b) use ($order) {
- $c = isset($order[$a]) ? $order[$a] : PHP_INT_MAX;
- $d = isset($order[$b]) ? $order[$b] : PHP_INT_MAX;
- // fall back to default comparison if not all values specified
- if ($c === PHP_INT_MAX and $d === PHP_INT_MAX) {
- return strcmp($a, $b);
- }
- return $c - $d;
- };
- } elseif ($options[static::SORT_NUMERIC]) {
- $comparator = function ($a, $b) {
- // for float numbers comparison.
- // round() function does not work here since round(-0.01) = -0,
- // but array.sort() expects -1.
- $c = $a - $b;
- if ($c < 0) {
- return -1;
- } elseif ($c > 0) {
- return 1;
- }
- return 0;
- };
- } elseif ($options[static::SORT_NATURAL] && $options[static::SORT_NO_CASE]) {
- $comparator = 'strnatcasecmp';
- } elseif ($options[static::SORT_NATURAL]) {
- $comparator = 'strnatcmp';
- } elseif ($options[static::SORT_NO_CASE]) {
- $comparator = 'strcasecmp';
- } else {
- $comparator = 'strcmp';
- }
-
- // optionally reverse the sort order by
- // inverting result of comparison function.
- if ($options[static::SORT_DESCENDING]) {
- return function ($a, $b) use ($comparator) {
- return call_user_func($comparator, $b, $a);
- };
- }
-
- return $comparator;
- }
-
- /**
- * Normalize sort options to ensure consistent structure
- * and to catch invalid/malformed options.
- *
- * @param array $options sort options @see sortBy()
- * @return array the normalized options array.
- * @throws \InvalidArgumentException if invalid/malformed options are found.
- */
- protected function normalizeSortOptions(array $options)
- {
- // ensure options are specified as option => value
- // instead of having the option name as the value
- // (value can be true/false or an array in the case
- // of sort fixed).
- $validSortOptions = $this->getValidSortOptions();
- $normalizedOptions = array_fill_keys($validSortOptions, false);
- foreach ($options as $key => $value) {
-
- // check if the key is a valid sort option.
- // if not, the value must be the sort option
- // otherwise, it's invalid.
- if (in_array($key, $validSortOptions, true)) {
- $normalizedOptions[$key] = $value;
- } elseif (in_array($value, $validSortOptions, true)) {
- $normalizedOptions[$value] = true;
- } else {
- throw new \InvalidArgumentException(
- "Unexpected sort option(s) encountered."
- );
- }
- }
-
- return $normalizedOptions;
- }
-
- /**
- * Get a list of the available sorting options.
- *
- * @return array all valid sort options.
- */
- protected function getValidSortOptions()
- {
- return array(
- static::SORT_ALPHA,
- static::SORT_ASCENDING,
- static::SORT_DESCENDING,
- static::SORT_FIXED,
- static::SORT_NATURAL,
- static::SORT_NO_CASE,
- static::SORT_NUMERIC
- );
- }
- }