Răsfoiți Sursa

Added Collection class for easy item set manipulations.

Taddeus Kroes 13 ani în urmă
părinte
comite
9b2c2614d4
2 a modificat fișierele cu 537 adăugiri și 0 ștergeri
  1. 313 0
      collection.php
  2. 224 0
      tests/test_collection.php

+ 313 - 0
collection.php

@@ -0,0 +1,313 @@
+<?php
+/**
+ * Collection functions, mainly used to manipulate lists of PHPActiveRecord models.
+ * 
+ * @author Taddeus Kroes
+ * @version 1.0
+ * @date 13-07-2012
+ */
+
+namespace WebBasics;
+
+require_once 'base.php';
+
+/**
+ * The Collection class contains a number of functions sort, index and manipulate
+ * an array of items.
+ * 
+ * Example 1: Index a list based on its values and extract a single attribute.
+ * <code>
+ * class Book extends PHPActiveRecord\Model {
+ *     static $attr_accessible = array('id', 'name');
+ * }
+ * 
+ * // Index all book names by their id.
+ * $books = Book::all();                      // Find a list of books
+ * $collection = new Collection($books);      // Put the list in a collection
+ * $indexed = $collection->index_by('id');    // Create indexes for all books
+ * $names = $indexed->get_attribute('name');  // Get the values of a single attribute
+ * // $names now contains something like array(1 => 'Some book', 2 => 'Another book')
+ * 
+ * // Same as above:
+ * $names = Collection::create(Book::all())->index_by('id')->get_attribute('name');
+ * </code>
+ * 
+ * Example 2: Execute a method for each item in a list.
+ * <code>
+ * // Delete all books
+ * Collection::create(Book::all())->map_method('delete');
+ * </code>
+ * 
+ * @package WebBasics
+ * @todo Finish unit tests
+ */
+class Collection extends Base {
+	/**
+	 * The set of items item that is being manipulated, as an associoative array.
+	 * 
+	 * @var array
+	 */
+	private $items;
+	
+	/**
+	 * Create a new Collection object.
+	 * 
+	 * @param array $items Initial item set (optional).
+	 */
+	function __construct(array $items=array()) {
+		$this->items = $items;
+	}
+	
+	/**
+	 * Add an item to the collection.
+	 * 
+	 * @param mixed $item The item to add.
+	 */
+	function add($item) {
+		$this->items[] = $item;
+	}
+	
+	/**
+	 * Insert an item at a specific index in the collection.
+	 * 
+	 * If the index is numeric, all existing numeric indices from that
+	 * index will be shifted one position to the right.
+	 * 
+	 * @param mixed $item The item to insert.
+	 * @param int|string $index The index at which to insert the item.
+	 * @throws \InvalidArgumentException If the added index already exists, and is non-numeric.
+	 */
+	function insert($item, $index) {
+		if( isset($this->items[$index]) ) {
+			if( !is_int($index) )
+				throw new \InvalidArgumentException(sprintf('Index "%s" already exists in this collection.', $index));
+			
+			for( $i = count($this->items) - 1; $i >= $index; $i--)
+				$this->items[$i + 1] = $this->items[$i];
+		}
+		
+		$this->items[$index] = $item;
+	}
+	
+	/**
+	 * Get all items in the collection as an array.
+	 * 
+	 * @return array The items in the collection.
+	 */
+	function all() {
+		return $this->items;
+	}
+	
+	/**
+	 * Get the first item in the collection.
+	 * 
+	 * @return mixed
+	 * @throws \OutOfBoundsException if the collection is empty.
+	 */
+	function first() {
+		if( !$this->count() )
+			throw new \OutOfBoundsException(sprintf('Cannot get first item: collection is empty.'));
+		
+		return $this->items[0];
+	}
+	
+	/**
+	 * Get the last item in the collection.
+	 * 
+	 * @return mixed
+	 * @throws \OutOfBoundsException if the collection is empty.
+	 */
+	function last() {
+		if( !$this->count() )
+			throw new \OutOfBoundsException(sprintf('Cannot get last item: collection is empty.'));
+		
+		return end($this->items);
+	}
+	
+	/**
+	 * Get the number of items in the collection.
+	 * 
+	 * @return int The number of items in the collection.
+	 */
+	function count() {
+		return count($this->items);
+	}
+	
+	/**
+	 * Check if an item with the given index exists.
+	 * 
+	 * @param int|string $index The index to check existance of.
+	 * @return bool Whether the index exists.
+	 */
+	function index_exists($index) {
+		return isset($this->items[$index]);
+	}
+	
+	/**
+	 * Get an item from the collection by its index.
+	 * 
+	 * @param int|string $index The index to the item to get.
+	 * @return mixed The value corresponding to the specified index.
+	 */
+	function get($index) {
+		return $this->items[$index];
+	}
+	
+	/**
+	 * Delete an item from the collection by its index.
+	 * 
+	 * @param int|string $index The index to the item to delete.
+	 */
+	function delete_index($index) {
+		unset($this->items[$index]);
+	}
+	
+	/**
+	 * Delete an item from the collection.
+	 * 
+	 * @param mixed $item The item to delete.
+	 */
+	function delete($item) {
+		$this->delete_index(array_search($item, $this->items));
+	}
+	
+	/**
+	 * Create a new item set with the same class name as this object.
+	 * 
+	 * @param array $items The new items to create a set with.
+	 * @param bool $clone If TRUE, the item set will overwrite the current
+	 *                    object's item set and not create a new object.
+	 * @return Collection A collection with the new item set.
+	 */
+	private function set_items(array $items, $clone=true) {
+		if( $clone )
+			return new self($items);
+		
+		$this->items = $items;
+		
+		return $this;
+	}
+	
+	/**
+	 * Remove duplicates from the current item set.
+	 * 
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @return Collection A collection without duplicates.
+	 */
+	function uniques($clone=false) {
+		return $this->set_items(array_values(array_unique($this->items)), $clone);
+	}
+	
+	/**
+	 * Filter items from the collection.
+	 * 
+	 * @param callable $callback Function that receives an item from the collection and
+	 *                           returns TRUE if the item should be present in the
+	 *                           resulting collection.
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @return Collection A collection with the filtered set of items.
+	 */
+	function filter($callback, $clone=true) {
+		return $this->set_items(array_values(array_filter($this->items, $callback)), $clone);
+	}
+	
+	/**
+	 * Find a subset of items in the collection using property value conditions.
+	 * 
+	 * The conditions are specified as an associative array of property names
+	 * pointing to values. Only items whose properties have these values will
+	 * appear in the resulting collection. The items in the collection have to
+	 * be objects or associative arrays, or an error will occur.
+	 * 
+	 * @param array $conditions The conditions that items in the subset should meet.
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @throws \UnexpectedValueException If a non-object and non-array value is encountered.
+	 * @return Collection
+	 */
+	function find(array $conditions, $clone=true) {
+		return $this->filter(function($item) use ($conditions) {
+			if( is_object($item) ) {
+				// Object, match property values
+				foreach( $conditions as $property => $value )
+					if( $item->{$property} != $value )
+						return false;
+			} elseif( is_array($item) ) {
+				// Array item, match array values
+				foreach( $conditions as $property => $value )
+					if( $item[$property] != $value )
+						return false;
+			} else {
+				// Other, incompatible type -> throw exception
+				throw new \UnexpectedValueException(
+					sprintf('Collection::find encountered a non-object and non-array item "%s".', $item)
+				);
+			}
+			
+			return true;
+		}, $clone);
+	}
+	
+	/**
+	 * Get an attribute value for each of the items in the collection.
+	 * 
+	 * The items are assumed to be objects with the specified attribute.
+	 * 
+	 * @param string $attribute The name of the attribute to get the value of.
+	 * @return array The original item keys, pointing to single attribute values.
+	 */
+	function get_attribute($attribute) {
+		return array_map(function($item) use ($attribute) {
+			return $item->{$attribute};
+		}, $this->items);
+	}
+	
+	/**
+	 * Use an attribute of each item in the collection as an index to that item.
+	 * 
+	 * The items are assumed to be objects with the specified index attribute.
+	 * 
+	 * @param string $attribute The name of the attribute to use as index value.
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @return Collection A collection object with the values of the attribute used as indices.
+	 */
+	function index_by($attribute, $clone=true) {
+		$indexed = array();
+		
+		foreach( $this->items as $item )
+			$indexed[$item->$attribute] = $item;
+		
+		return $this->set_items($indexed, $clone);
+	}
+	
+	/**
+	 * Execute a callback for each of the items in the collection.
+	 * 
+	 * @param callable $callback Function that receives an item from the collection.
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @return Collection A collection with return values of the callback calls.
+	 */
+	function map($callback, $clone=true) {
+		return $this->set_items(array_map($callback, $this->items), $clone);
+	}
+	
+	/**
+	 * Execute an object method for each item in the collection.
+	 * 
+	 * The items are assumed to be objects with the specified method.
+	 * 
+	 * @param string $method_name The name of the method to execute.
+	 * @param array $args Any arguments to pass to the method.
+	 * @param bool $clone Whether to create a new object, or overwrite the current item set.
+	 * @return Collection A collection with return values of the method calls.
+	 */
+	function map_method($method_name, array $args=array(), $clone=true) {
+		$items = array();
+		
+		foreach( $this->items as $item )
+			$items[] = call_user_func_array(array($item, $method_name), $args);
+		
+		return $this->set_items($items);
+	}
+}
+
+?>

+ 224 - 0
tests/test_collection.php

@@ -0,0 +1,224 @@
+<?php
+
+require_once 'collection.php';
+use WebBasics\Collection;
+
+class IdObject {
+	static $count = 0;
+	
+	function __construct($foo=null) {
+		$this->id = ++self::$count;
+		$this->foo = $foo;
+	}
+	
+	function get_id() {
+		return $this->id;
+	}
+	
+	static function clear_counter() {
+		self::$count = 0;
+	}
+}
+
+function set($items=array()) {
+	return new Collection($items);
+}
+
+
+function std_object(array $properties) {
+	$object = new stdClass();
+	
+	foreach( $properties as $property => $value )
+		$object->{$property} = $value;
+	
+	return $object;
+}
+
+class CollectionTest extends PHPUnit_Framework_TestCase {
+	function setUp() {
+		$this->set = set(array(1, 2));
+	}
+	
+	function test_add() {
+		$this->set->add(3);
+		$this->assertEquals($this->set, set(array(1, 2, 3)));
+	}
+	
+	/**
+	 * @expectedException InvalidArgumentException
+	 */
+	function test_insert_error() {
+		set(array('foo' => 1))->insert(2, 'foo');
+	}
+	
+	function test_insert_success() {
+		$this->set->insert(4, 1);
+		$this->assertEquals($this->set, set(array(1, 4, 2)));
+		$this->set->insert(5, 0);
+		$this->assertEquals($this->set, set(array(5, 1, 4, 2)));
+	}
+	
+	function test_all() {
+		$this->assertEquals(set()->all(), array());
+		$this->assertEquals(set(array())->all(), array());
+		$this->assertEquals(set(array(1))->all(), array(1));
+		$this->assertEquals(set(array(1, 2))->all(), array(1, 2));
+	}
+	
+	/**
+	 * @expectedException OutOfBoundsException
+	 */
+	function test_last_empty() {
+		set()->last();
+	}
+	
+	function test_last() {
+		$this->assertEquals($this->set->last(), 2);
+	}
+	
+	/**
+	 * @expectedException OutOfBoundsException
+	 */
+	function test_first_empty() {
+		set()->first();
+	}
+	
+	function test_first() {
+		$this->assertEquals($this->set->first(), 1);
+	}
+	
+	function test_count() {
+		$this->assertEquals(set()->count(), 0);
+		$this->assertEquals(set(array())->count(), 0);
+		$this->assertEquals(set(array(1))->count(), 1);
+		$this->assertEquals(set(array(1, 2))->count(), 2);
+	}
+	
+	function test_index_exists() {
+		$this->assertTrue($this->set->index_exists(1));
+		$this->assertTrue(set(array('foo' => 'bar'))->index_exists('foo'));
+	}
+	
+	function test_get() {
+		$this->assertEquals($this->set->get(0), 1);
+		$this->assertEquals($this->set->get(1), 2);
+		$this->assertEquals(set(array('foo' => 'bar'))->get('foo'), 'bar');
+	}
+	
+	function test_delete_index() {
+		$this->set->delete_index(0);
+		$this->assertEquals($this->set, set(array(1 => 2)));
+	}
+	
+	function test_delete() {
+		$this->set->delete(1);
+		$this->assertEquals($this->set, set(array(1 => 2)));
+	}
+	
+	function assert_set_equals(array $expected_items, $set) {
+		$this->assertAttributeEquals($expected_items, 'items', $set);
+	}
+	
+	function test_uniques() {
+		$this->assert_set_equals(array(1, 2), set(array(1, 2, 2))->uniques());
+		$this->assert_set_equals(array(2, 1), set(array(2, 1, 2))->uniques());
+		$this->assert_set_equals(array(2, 1), set(array(2, 2, 1))->uniques());
+	}
+	
+	function set_items($collection, $items, $clone) {
+		$rm = new ReflectionMethod($collection, 'set_items');
+		$rm->setAccessible(true);
+		return $rm->invoke($collection, $items, $clone);
+	}
+	
+	function test_set_items_clone() {
+		$result = $this->set_items($this->set, array(3, 4), true);
+		$this->assert_set_equals(array(1, 2), $this->set);
+		$this->assert_set_equals(array(3, 4), $result);
+		$this->assertNotSame($this->set, $result);
+	}
+	
+	function test_set_items_no_clone() {
+		$result = $this->set_items($this->set, array(3, 4), false);
+		$this->assertSame($this->set, $result);
+	}
+	
+	/**
+	 * @depends test_set_items_clone
+	 */
+	function test_filter() {
+		$smaller_than_five = function($number) { return $number < 5; };
+		$this->assert_set_equals(array(2, 4, 1, 4), set(array(2, 7, 4, 7, 1, 8, 4, 5))->filter($smaller_than_five));
+	}
+	
+	/**
+	 * @depends test_filter
+	 */
+	function test_find_success() {
+		$items = array(
+			array('foo' => 'bar', 'bar' => 'baz'),
+			array('foo' => 'baz', 'bar' => 'foo'),
+			std_object(array('foo' => 'bar', 'baz' => 'bar')),
+		);
+		$this->assert_set_equals(array($items[1]), set($items)->find(array('foo' => 'baz')));
+		$this->assert_set_equals(array($items[0], $items[2]), set($items)->find(array('foo' => 'bar')));
+	}
+	
+	/**
+	 * @depends test_find_success
+	 * @expectedException \UnexpectedValueException
+	 * @expectedExceptionMessage Collection::find encountered a non-object and non-array item "foobar".
+	 */
+	function test_find_failure() {
+		$items = array(
+			array('foo' => 'bar', 'bar' => 'baz'),
+			'foobar',
+		);
+		set($items)->find(array('foo' => 'bar'));
+	}
+	
+	function test_get_attribute_simple() {
+		IdObject::clear_counter();
+		$set = set(array(new IdObject(), new IdObject(), new IdObject()));
+		$this->assertEquals(array(1, 2, 3), $set->get_attribute('id'));
+	}
+	
+	/**
+	 * @depends test_get_attribute_simple
+	 */
+	function test_get_attribute_indices() {
+		IdObject::clear_counter();
+		$set = set(array('foo' => new IdObject(), 'bar' => new IdObject(), 'baz' => new IdObject()));
+		$this->assertEquals(array('foo' => 1, 'bar' => 2, 'baz' => 3), $set->get_attribute('id'));
+	}
+	
+	/**
+	 * @depends test_all
+	 * @depends test_set_items_clone
+	 */
+	function test_index_by() {
+		IdObject::clear_counter();
+		$set = set(array(new IdObject('foo'), new IdObject('bar'), new IdObject('baz')));
+		list($foo, $bar, $baz) = $set->all();
+		$this->assert_set_equals(array('foo' => $foo, 'bar' => $bar, 'baz' => $baz), $set->index_by('foo'));
+	}
+	
+	/**
+	 * @depends test_set_items_clone
+	 */
+	function test_map() {
+		$plus_five = function($number) { return $number + 5; };
+		$this->assert_set_equals(array(6, 7, 8), set(array(1, 2, 3))->map($plus_five));
+	}
+	
+	/**
+	 * @depends test_set_items_clone
+	 */
+	function test_map_method() {
+		IdObject::clear_counter();
+		$set = set(array(new IdObject(), new IdObject(), new IdObject()));
+		$this->assert_set_equals(array(1, 2, 3), $set->map_method('get_id'));
+	}
+}
+
+?>