brokencube/automatorm

View on GitHub
src/Orm/Collection.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
namespace Automatorm\Orm;

use Automatorm\Exception;
use Automatorm\OperatorParser;
use Automatorm\Interfaces\WrappedModel;

class Collection implements \ArrayAccess, \Iterator, \Countable, \JsonSerializable
{
    // From Hodgepodge
    protected $container = [];

    //////// Interface Methods ///////
    public function jsonSerialize()
    {
        return $this->container;
    }
    
    public function offsetSet($offset, $value)
    {
        if (is_null($offset)) {
            $this->container[] = $value;
        } else {
            $this->container[$offset] = $value;
        }
    }

    public function offsetExists($offset)
    {
        return isset($this->container[$offset]);
    }

    public function offsetUnset($offset)
    {
        unset($this->container[$offset]);
    }

    public function offsetGet($offset)
    {
        return isset($this->container[$offset]) ? $this->container[$offset] : null;
    }
    
    public function rewind()
    {
        reset($this->container);
    }
    
    public function current()
    {
        return current($this->container);
    }

    public function key()
    {
        return key($this->container);
    }

    public function next()
    {
        return next($this->container);
    }
    
    public function valid()
    {
        return current($this->container) !== false;
    }
    
    /**
     * Return number of items in array
     * @return int Number of items in array
     */
    
    public function count()
    {
        return count($this->container);
    }
    
    /**
     * Return first item specified in array (regardless of key) or null if empty array
     * @return mixed First item or null
     */
    public function first()
    {
        if (!count($this->container)) {
            return null;
        }
        return array_slice($this->container, 0, 1)[0];
    }

    /**
     * Return last item specified in array (regardless of key) or null if empty array
     * @return mixed Last item or null
     */
    public function last()
    {
        if (!count($this->container)) {
            return null;
        }
        return array_slice($this->container, count($this->container) - 1, 1)[0];
    }
    
    ////////////////////
    
    public function __get($parameter)
    {
        $list = array();
        
        if ($this->container[0] instanceof Model and $this->container[0]->_data->externalKeyExists($parameter)) {
            return Data::groupJoin($this, $parameter);
        }

        if ($this->container[0] instanceof WrappedModel and !property_exists($this->container[0], $parameter) and $this->container[0]->_data->externalKeyExists($parameter)) {
            return Data::groupJoin($this->map(function ($a) { return $a->getModel(); }), $parameter);
        }
        
        foreach ($this->container as $item) {
            $value = $item->$parameter;
            if ($value instanceof Collection) {
                $list = array_merge($list, $value->toArray());
            } else {
                $list[] = $value;
            }
        }
        
        return new static($list);
    }

    public function __call($name, $args)
    {
        // If we use Model::COUNT_ONLY on empty container, return 0
        if (count($this->container) == 0 && is_numeric($args[1]) && ($args[1] & Model::COUNT_ONLY)) {
            return 0;
        }
        
        // Foreign keys
        if ($this->container[0] instanceof Model
            and !method_exists($this->container[0], $name)
            and $this->container[0]->_data->externalKeyExists($name)
        ) {
            if (is_numeric($args[1]) && ($args[1] & Model::COUNT_ONLY)) {
                return Data::groupJoin($this, $name, $args[0], true);
            }
            return Data::groupJoin($this, $name, $args[0]);
        }
        
        // Otherwise...
        $list = [];
        foreach ($this->container as $item) {
            $value = call_user_func_array([$item, $name], $args);
            if ($value instanceof Collection) {
                $list = array_merge($list, $value->toArray());
            } else {
                $list[] = $value;
            }
        }
        
        // Return new list or count depending on options passed
        if (is_numeric($args[1]) && ($args[1] & Model::COUNT_ONLY)) {
            return count($list);
        } else {
            return new static($list);
        }
    }
    
    public function call($name, $args = [])
    {
        return $this->__call($name, $args);
    }
    
    public function __set($name, $arg)
    {
        throw new Exception\Collection('Cannot directly set properties on a collection', ['name' => $name, 'value' => $arg]);
    }

    public function __construct($array = null)
    {
        if (is_null($array)) {
            $array = array();
        }
        if ($array instanceof Collection) {
            $array = $array->toArray();
        }
        if (!is_array($array)) {
            throw new \InvalidArgumentException('Orm\Collection::__construct() expects an array - ' . gettype($array) . ' given');
        }
        
        $this->container = array_values($array);
    }
    
    /**
     * Return a plain PHP array version of the internal container
     * Additional options for the normal case of a Collection of Model objects
     *
     * @return array internal array container
     */
    public function toArray() : array
    {
        return $this->container;
    }

    /**
     * Return a plain PHP array version of the internal container using the supplied key/value values of the model object
     * @param $key For Collections of Models, return the specified property name as the "Key", or numeric array if null
     * @param $value For Collections of Models, return the specified property name instead of the Model object as the "Value"
     *
     * @return array of key => value as specified based on the objects in the collection
     */
    public function toAssociativeArray($key, $value) : array
    {
        // Empty array?
        if (!count($this->container)) {
            return [];
        }
        
        // If we are not dealing with a collection of objects, just return the internal container
        if (!is_object($this->container[0])) {
            return $this->container;
        }
        
        // If we are dealing with a collection of objects then user key/value to extract desired property
        $return = [];
        foreach ($this->container as $item) {
            $return[$item->$key] = $item->$value;
        }
        return $return;
    }
    
    //////// Collection modifiers ////////
    /**
     * Return a new Collection containing one copy of each unique value in the original Container
     *
     * @return self New Collection containing only the unique values available in the original container
     */
    public function unique()
    {
        $copy = $this->container;
        $clobberlist = [];
        $modelclobberlist = [];
        
        foreach ($copy as $key => $obj) {
            if ($obj instanceof Model) {
                if (in_array($obj->id, $modelclobberlist)) {
                    unset($copy[$key]);
                } else {
                    $modelclobberlist[] = $obj->id;
                }
            } else {
                if (in_array($obj, $clobberlist)) {
                    unset($copy[$key]);
                } else {
                    $clobberlist[] = $obj;
                }
            }
        }
        
        return new static($copy);
    }
    
    /**
     * Return a new sorted Collection using provided sort function (through uasort())
     *
     * @param callable $function Callable to use to sort the array
     * @return self New Collection containing the sorted items
     */
    public function sort(callable $function)
    {
        $copy = $this->container;
        uasort($copy, $function);
        return new static($copy);
    }
    
    /**
     * Return a new sorted Collection using the already ordered list of Ids
     * Only works for collections containing Model objects
     *
     * @param callable $function Callable to use to sort the array
     * @return self New Collection containing the sorted items
     */
    public function sortById(array $listOfIds)
    {
        // Don't bother trying to sort an empty container, just return a new empty container
        if (!$this->container) return new static();
        
        if (!$this->first() instanceof Model) {
            throw new Exception\BaseException('sortById can only be called on collections of Model objects');
        }
        $order = array_values($listOfIds);
        
        return $this->sort(function ($a, $b) use ($order) {
            return array_search($a->id, $order) - array_search($b->id, $order);
        });
    }

    /**
     * Return a new sorted Collection using provided sort function (through uasort() + strnatcasecmp())
     *
     * @param callable $key Specify the property on the Collection items to sort by -
     *                      otherwise, objects will be sorted by their toString representation
     * @return self New Collection containing the sorted items
     */
    public function natSort($key = null)
    {
        if (!$key) {
            return $this->sort(function ($a, $b) {
                return strnatcasecmp((string) $a, (string) $b);
            });
        } else {
            return $this->sort(function ($a, $b) use ($key) {
                return strnatcasecmp($a->{$key}, $b->{$key});
            });
        }
    }
    
    public function slice($start, $length = null)
    {
        return new static(array_slice($this->container, $start, $length));
    }
    
    public function reverse()
    {
        return new static(array_reverse($this->container));
    }
    
    // Merge another array into this collection
    public function add()
    {
        $args = func_get_args();
        $merge = [$this->container];
        
        $count = 1;
        foreach ($args as $array) {
            if ($array instanceof Collection) {
                $array = $array->container;
            }
            if (!is_array($array)) {
                throw new \InvalidArgumentException("Orm\Collection->add() expects argument {$count} to be an array");
            }
            $merge[] = $array;
            $count++;
        }
        
        $copy = call_user_func_array('array_merge', $merge);
        
        return new static($copy);
    }

    public function merge($array)
    {
        return $this->add($array);
    }
    
    // Remove any items in this collection that match filter
    public function not($filter)
    {
        return $this->filter($filter, true);
    }

    public function remove($filter)
    {
        return $this->filter($filter, true);
    }
    
    public function map(callable $function)
    {
        return new static(array_map($function, $this->container));
    }
    
    // Only keep items that match filter
    public function filter($filter, $invertAffix = false)
    {
        $copy = $this->container;
        
        if (is_array($filter)) {
            // Loop over items
            foreach ($copy as $itemKey => $item) {
                // Loop over filters
                foreach ($filter as $property => $valueList) {
                    list($affix, $property) = OperatorParser::extractAffix($property, $invertAffix);
                    
                    // Each filter can have several acceptable values -- force single item to array
                    if (!is_array($valueList)) {
                        $valueList = array($valueList);
                    }
                    
                    // Check each value - if we find a matching value than skip to the next filter.
                    foreach ($valueList as $value) {
                        $compare = OperatorParser::testOperator($affix, $item->$property, $value);

                        // Compare based on affix (else == )
                        switch ($affix) {
                            case '=':
                                if ($compare) {
                                    // Found match - move on to next filter
                                    continue 3; // Back to foreach $filter
                                }
                                break;

                            default:
                                if (!$compare) {
                                    // Found a negative match, remove this item and move on to next property
                                    unset($copy[$itemKey]);
                                    continue 4; // Back to foreach $copy
                                }
                                break;
                        }
                    }
                    
                    switch ($affix) {
                        // Negative cases
                        case '=':
                            // Failed to break loop, so the current value matches none of the
                            // values for the current filter, therefore remove the item
                            unset($copy[$itemKey]);
                            continue 3; // Back to foreach $copy
                        
                        // Positive cases
                        default:
                            // Failed to break oop, so the current value passes.
                            // No action, keep this key, continue to next filter
                            continue 2; // Back to foreach $filter
                    }
                }
            }
        } elseif (is_callable($filter)) {
            // Loop over items
            foreach ($copy as $itemKey => $item) {
                // Use the closure/callback to filter the item
                if (!$filter($item)) {
                    unset($copy[$itemKey]);
                }
            }
        } else {
            throw new \InvalidArgumentException('Orm\Collection->filter() expects an array or callable');
        }
        
        $copy = array_values($copy);
        
        return new static($copy);
    }
}