Iterator.php #1

  • //
  • guest/
  • thomas_gray/
  • jambox/
  • main/
  • swarm/
  • library/
  • P4/
  • Model/
  • Fielded/
  • Iterator.php
  • View
  • Commits
  • Open Download .zip Download (18 KB)
<?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
        );
    }
}
# Change User Description Committed
#1 18334 Liz Lam initial add of jambox