Browse Source

Merge pull request #2793 from markstory/3.0-collection-context

3.0 collection context
José Lorenzo Rodríguez 12 years ago
parent
commit
7e0c1dee94
2 changed files with 275 additions and 19 deletions
  1. 37 19
      src/View/Form/EntityContext.php
  2. 238 0
      tests/TestCase/View/Form/EntityContextTest.php

+ 37 - 19
src/View/Form/EntityContext.php

@@ -14,14 +14,17 @@
  */
 namespace Cake\View\Form;
 
+use Cake\Collection\Collection;
 use Cake\Network\Request;
 use Cake\ORM\Entity;
 use Cake\ORM\TableRegistry;
+use Cake\Utility\Inflector;
 use Cake\Validation\Validator;
 use Traversable;
 
 /**
  * Provides a form context around a single entity and its relations.
+ * It also can be used as context around an array or iterator of entities.
  *
  * This class lets FormHelper interface with entities or collections
  * of entities.
@@ -32,7 +35,8 @@ use Traversable;
  * - `table` Either the ORM\Table instance to fetch schema/validators
  *   from, an array of table instances in the case of a form spanning
  *   multiple entities, or the name(s) of the table.
- *   If this is null the table name(s) will be determined using conventions.
+ *   If this is null the table name(s) will be determined using naming
+ *   conventions.
  * - `validator` Either the Validation\Validator to use, or the name of the
  *   validation method to call on the table object. For example 'default'.
  *   Defaults to 'default'. Can be an array of table alias=>validators when
@@ -88,13 +92,39 @@ class EntityContext {
 /**
  * Prepare some additional data from the context.
  *
+ * If the table option was provided to the constructor and it
+ * was a string, ORM\TableRegistry will be used to get the correct table instance.
+ *
+ * If an object is provided as the table option, it will be used as is.
+ *
+ * If no table option is provided, the table name will be derived based on
+ * naming conventions. This inference will work with a number of common objects
+ * like arrays, Collection objects and ResultSets.
+ *
  * @return void
+ * @throws \RuntimeException When a table object cannot be located/inferred.
  */
 	protected function _prepare() {
 		$table = $this->_context['table'];
+		if (empty($table)) {
+			$entity = $this->_context['entity'];
+			if (is_array($entity) || $entity instanceof Traversable) {
+				$entity = (new Collection($entity))->first();
+			}
+			if ($entity instanceof Entity) {
+				list($ns, $entityClass) = namespaceSplit(get_class($entity));
+				$table = Inflector::pluralize($entityClass);
+			}
+		}
 		if (is_string($table)) {
 			$table = TableRegistry::get($table);
 		}
+
+		if (!is_object($table)) {
+			throw new \RuntimeException(
+				'Unable to find table class for current entity'
+			);
+		}
 		$alias = $this->_rootName = $table->alias();
 		$this->_tables[$alias] = $table;
 	}
@@ -113,9 +143,6 @@ class EntityContext {
 		}
 		$parts = explode('.', $field);
 		list($entity, $prop) = $this->_getEntity($parts);
-		if (!$entity) {
-			return null;
-		}
 		return $entity->get(array_pop($parts));
 	}
 
@@ -128,6 +155,7 @@ class EntityContext {
  *
  * @param array $path The path to traverse to find the leaf entity.
  * @return array
+ * @throws \RuntimeException When properties cannot be read.
  */
 	protected function _getEntity($path) {
 		$entity = $this->_context['entity'];
@@ -150,7 +178,10 @@ class EntityContext {
 			}
 			$entity = $next;
 		}
-		return [false, false];
+		throw \RuntimeException(sprintf(
+			'Unable to fetch property "%s"',
+			implode(".", $path)
+		));
 	}
 
 /**
@@ -183,15 +214,8 @@ class EntityContext {
 		}
 		$parts = explode('.', $field);
 		list($entity, $prop) = $this->_getEntity($parts);
-		if (!$entity) {
-			return false;
-		}
 
 		$validator = $this->_getValidator($prop);
-		if (!$validator) {
-			return false;
-		}
-
 		$field = array_pop($parts);
 		if (!$validator->hasField($field)) {
 			return false;
@@ -205,7 +229,7 @@ class EntityContext {
  * conventions.
  *
  * @param string $entity The entity name to get a validator for.
- * @return Validator|false
+ * @return Validator
  */
 	protected function _getValidator($entity) {
 		$table = $this->_getTable($entity);
@@ -254,9 +278,6 @@ class EntityContext {
 	public function type($field) {
 		$parts = explode('.', $field);
 		list($entity, $prop) = $this->_getEntity($parts);
-		if (!$entity) {
-			return null;
-		}
 		$table = $this->_getTable($prop);
 		$column = array_pop($parts);
 		return $table->schema()->columnType($column);
@@ -271,9 +292,6 @@ class EntityContext {
 	public function attributes($field) {
 		$parts = explode('.', $field);
 		list($entity, $prop) = $this->_getEntity($parts);
-		if (!$entity) {
-			return [];
-		}
 		$table = $this->_getTable($prop);
 		$column = $table->schema()->column(array_pop($parts));
 		$whitelist = ['length' => null, 'precision' => null];

+ 238 - 0
tests/TestCase/View/Form/EntityContextTest.php

@@ -14,6 +14,9 @@
  */
 namespace Cake\Test\TestCase\View\Form;
 
+use ArrayIterator;
+use ArrayObject;
+use Cake\Collection\Collection;
 use Cake\Network\Request;
 use Cake\ORM\Entity;
 use Cake\ORM\Table;
@@ -23,6 +26,12 @@ use Cake\Validation\Validator;
 use Cake\View\Form\EntityContext;
 
 /**
+ * Test stub.
+ */
+class Article extends Entity {
+}
+
+/**
  * Entity context test case.
  */
 class EntityContextTest extends TestCase {
@@ -45,6 +54,202 @@ class EntityContextTest extends TestCase {
 	}
 
 /**
+ * Test an invalid table scope throws an error.
+ *
+ * @expectedException \RuntimeException
+ * @expectedExceptionMessage Unable to find table class for current entity
+ */
+	public function testInvalidTable() {
+		$row = new \StdClass();
+		$context = new EntityContext($this->request, [
+			'entity' => $row,
+		]);
+	}
+
+/**
+ * Test operations with no entity.
+ *
+ * @return void
+ */
+	public function testOperationsNoEntity() {
+		$context = new EntityContext($this->request, [
+			'table' => 'Articles'
+		]);
+
+		$this->assertNull($context->val('title'));
+		$this->assertFalse($context->isRequired('title'));
+		$this->assertFalse($context->hasError('title'));
+		$this->assertEquals('string', $context->type('title'));
+		$this->assertEquals([], $context->error('title'));
+		$this->assertEquals(
+			['length' => null, 'precision' => null],
+			$context->attributes('title')
+		);
+	}
+
+/**
+ * Test operations that lack a table argument.
+ *
+ * @return void
+ */
+	public function testOperationsNoTableArg() {
+		$row = new Article([
+			'title' => 'Test entity',
+			'body' => 'Something new'
+		]);
+		$row->errors('title', ['Title is required.']);
+
+		$context = new EntityContext($this->request, [
+			'entity' => $row,
+		]);
+
+		$result = $context->val('title');
+		$this->assertEquals($row->title, $result);
+
+		$result = $context->error('title');
+		$this->assertEquals($row->errors('title'), $result);
+		$this->assertTrue($context->hasError('title'));
+	}
+
+/**
+ * Test collection operations that lack a table argument.
+ *
+ * @dataProvider collectionProvider
+ * @return void
+ */
+	public function testCollectionOperationsNoTableArg($collection) {
+		$context = new EntityContext($this->request, [
+			'entity' => $collection,
+		]);
+
+		$result = $context->val('0.title');
+		$this->assertEquals('First post', $result);
+
+		$result = $context->error('1.body');
+		$this->assertEquals(['Not long enough'], $result);
+	}
+
+/**
+ * Data provider for testing collections.
+ *
+ * @return array
+ */
+	public static function collectionProvider() {
+		$one = new Entity([
+			'title' => 'First post',
+			'body' => 'Stuff',
+			'user' => new Entity(['username' => 'mark'])
+		]);
+		$one->errors('title', 'Required field');
+
+		$two = new Entity([
+			'title' => 'Second post',
+			'body' => 'Some text',
+			'user' => new Entity(['username' => 'jose'])
+		]);
+		$two->errors('body', 'Not long enough');
+
+		return [
+			'array' => [[$one, $two]],
+			'basic iterator' => [new ArrayObject([$one, $two])],
+			'array iterator' => [new ArrayIterator([$one, $two])],
+			'collection' => [new Collection([$one, $two])],
+		];
+	}
+
+/**
+ * Test operations on a collection of entities.
+ *
+ * @dataProvider collectionProvider
+ * @return void
+ */
+	public function testValOnCollections($collection) {
+		$context = new EntityContext($this->request, [
+			'entity' => $collection,
+			'table' => 'Articles',
+		]);
+
+		$result = $context->val('0.title');
+		$this->assertEquals('First post', $result);
+
+		$result = $context->val('0.user.username');
+		$this->assertEquals('mark', $result);
+
+		$result = $context->val('1.title');
+		$this->assertEquals('Second post', $result);
+
+		$result = $context->val('1.user.username');
+		$this->assertEquals('jose', $result);
+	}
+
+/**
+ * Test error operations on a collection of entities.
+ *
+ * @dataProvider collectionProvider
+ * @return void
+ */
+	public function testErrorsOnCollections($collection) {
+		$context = new EntityContext($this->request, [
+			'entity' => $collection,
+			'table' => 'Articles',
+		]);
+
+		$this->assertTrue($context->hasError('0.title'));
+		$this->assertEquals(['Required field'], $context->error('0.title'));
+		$this->assertFalse($context->hasError('0.body'));
+
+		$this->assertFalse($context->hasError('1.title'));
+		$this->assertEquals(['Not long enough'], $context->error('1.body'));
+		$this->assertTrue($context->hasError('1.body'));
+	}
+
+/**
+ * Test schema operations on a collection of entities.
+ *
+ * @dataProvider collectionProvider
+ * @return void
+ */
+	public function testSchemaOnCollections($collection) {
+		$this->_setupTables();
+		$context = new EntityContext($this->request, [
+			'entity' => $collection,
+			'table' => 'Articles',
+		]);
+
+		$this->assertEquals('string', $context->type('0.title'));
+		$this->assertEquals('text', $context->type('1.body'));
+		$this->assertEquals('string', $context->type('0.user.username'));
+		$this->assertEquals('string', $context->type('1.user.username'));
+		$this->assertNull($context->type('0.nope'));
+
+		$expected = ['length' => 255, 'precision' => null];
+		$this->assertEquals($expected, $context->attributes('0.user.username'));
+	}
+
+/**
+ * Test validation operations on a collection of entities.
+ *
+ * @dataProvider collectionProvider
+ * @return void
+ */
+	public function testValidatorsOnCollections($collection) {
+		$this->_setupTables();
+
+		$context = new EntityContext($this->request, [
+			'entity' => $collection,
+			'table' => 'Articles',
+			'validator' => [
+				'Articles' => 'create',
+				'Users' => 'custom',
+			]
+		]);
+
+		$this->assertTrue($context->isRequired('0.title'));
+		$this->assertFalse($context->isRequired('1.body'));
+		$this->assertTrue($context->isRequired('0.user.username'));
+	}
+
+/**
  * Test reading data.
  *
  * @return void
@@ -107,6 +312,35 @@ class EntityContextTest extends TestCase {
 	}
 
 /**
+ * Test reading values from associated entities.
+ *
+ * @return void
+ */
+	public function testValAssociatedHasMany() {
+		$row = new Entity([
+			'title' => 'First post',
+			'user' => new Entity([
+				'username' => 'mark',
+				'fname' => 'Mark',
+				'articles' => [
+					new Entity(['title' => 'First post']),
+					new Entity(['title' => 'Second post']),
+				]
+			]),
+		]);
+		$context = new EntityContext($this->request, [
+			'entity' => $row,
+			'table' => 'Articles',
+		]);
+
+		$result = $context->val('user.articles.0.title');
+		$this->assertEquals('First post', $result);
+
+		$result = $context->val('user.articles.1.title');
+		$this->assertEquals('Second post', $result);
+	}
+
+/**
  * Test validator as a string.
  *
  * @return void
@@ -125,6 +359,7 @@ class EntityContextTest extends TestCase {
 
 		$this->assertFalse($context->isRequired('Herp.derp.derp'));
 		$this->assertFalse($context->isRequired('nope'));
+		$this->assertFalse($context->isRequired(''));
 	}
 
 /**
@@ -156,6 +391,8 @@ class EntityContextTest extends TestCase {
 
 		$this->assertTrue($context->isRequired('comments.0.user_id'));
 		$this->assertFalse($context->isRequired('comments.0.other'));
+		$this->assertFalse($context->isRequired('user.0.other'));
+		$this->assertFalse($context->isRequired(''));
 	}
 
 /**
@@ -386,6 +623,7 @@ class EntityContextTest extends TestCase {
 
 		$comments = TableRegistry::get('Comments');
 		$users = TableRegistry::get('Users');
+		$users->hasMany('Articles');
 
 		$articles->schema([
 			'id' => ['type' => 'integer', 'length' => 11, 'null' => false],