← Back to team overview

kolibri-discuss team mailing list archive

[Merge] lp:~asteinlein/kolibri/nested-routes-mapper into lp:kolibri

 

Anders Steinlein has proposed merging lp:~asteinlein/kolibri/nested-routes-mapper into lp:kolibri.

Requested reviews:
    Kolibri Dev (kolibri-dev)

This branch has developed into more of a "new stuff" branch than only nested routes, so a look through all changes is required. However, the most important changes (apart from a few bugfixes) are the nested routes and support for Selenium integration testing, both of which are currently being used in development of our MailMojo app.
-- 
https://code.launchpad.net/~asteinlein/kolibri/nested-routes-mapper/+merge/6966
Your team Kolibri Discuss is subscribed to branch lp:kolibri.
=== renamed file 'examples/wishlist/actions/items/Add.php' => 'examples/wishlist/actions/items/ItemsAdd.php'
--- examples/wishlist/actions/items/Add.php	2009-04-14 16:50:50 +0000
+++ examples/wishlist/actions/items/ItemsAdd.php	2009-05-10 13:27:03 +0000
@@ -3,7 +3,7 @@
  * Action for adding new item. We implement ModelAware to have the model object populated by
  * request data, and ValidationAware to have it automatically validated.
  */
-class Add implements MessageAware, ModelAware, ValidationAware {
+class ItemsAdd implements MessageAware, ModelAware, ValidationAware {
 	/**
 	 * Defines the model class to instantiate, which will be populated with request data and
 	 * put back into this property.

=== renamed file 'examples/wishlist/actions/items/Del.php' => 'examples/wishlist/actions/items/ItemsDel.php'
--- examples/wishlist/actions/items/Del.php	2009-04-15 21:10:58 +0000
+++ examples/wishlist/actions/items/ItemsDel.php	2009-05-10 13:27:03 +0000
@@ -3,13 +3,13 @@
  * Action for deleting items. As the item to delete is specified by the last URI element (which
  * does not have a matching action file), it is implicitly put in the "id" request parameter.
  */
-class Del implements MessageAware {
+class ItemsDel implements MessageAware {
 	/**
 	 * TODO: We should really POST the form (and thus doPost()).
 	 */
 	public function doGet ($request) {
 		// We could also do $request->get('id'), whatever you prefer
-		$itemName = $request['id'];
+		$itemName = $request['itemsid'];
 		$item = Models::init('Item');
 
 		// Tries to load the item (notice that this calls load() in the ItemDao class)

=== renamed file 'examples/wishlist/actions/items/Have.php' => 'examples/wishlist/actions/items/ItemsHave.php'
--- examples/wishlist/actions/items/Have.php	2009-04-15 19:55:02 +0000
+++ examples/wishlist/actions/items/ItemsHave.php	2009-05-10 13:27:03 +0000
@@ -2,13 +2,13 @@
 /**
  * Action for marking items as received.
  */
-class Have implements MessageAware {
+class ItemsHave implements MessageAware {
 	/**
 	 * TODO: We should really POST the form (and thus doPost()).
 	 */
 	public function doGet ($request) {
 		$item = Models::init('Item');
-		if ($item->objects->load($request['id'])) {
+		if ($item->objects->load($request['itemsid'])) {
 			// Set received date (could be done in SQL, but just to show date library in use)
 			$df = DateFormat::getInstance(DateFormat::ISO_8601_DATE);
 			$item->received = $df->format(new Date());
@@ -17,7 +17,7 @@
 			$this->msg->setMessage('Item successfully marked as received.');
 		}
 		else {
-			$this->msg->setMessage("Item with name {$request['id']} not found.", false);
+			$this->msg->setMessage("Item with name {$request['itemsid']} not found.", false);
 		}
 
 		return new RedirectResponse('/');

=== modified file 'examples/wishlist/views/index.xsl'
--- examples/wishlist/views/index.xsl	2008-12-17 15:50:47 +0000
+++ examples/wishlist/views/index.xsl	2009-05-10 13:27:03 +0000
@@ -49,9 +49,9 @@
 				<span class="price">Price: <xsl:value-of select="price" /></span>
 			</xsl:if>
 			<p class="actions">
-				<a href="{$webRoot}/items/have/{name}">Got it!</a>
+				<a href="{$webRoot}/items/{name}/have">Got it!</a>
 				/
-				<a href="{$webRoot}/items/del/{name}">Nah, don't want anymore</a>
+				<a href="{$webRoot}/items/{name}/del">Nah, don't want anymore</a>
 			</p>
 		</div>
 	</xsl:template>
@@ -62,7 +62,7 @@
 			<h3><xsl:value-of select="name" /></h3>
 			<p><xsl:value-of select="description" /></p>
 			<p class="actions">
-				<a href="{$webRoot}/items/del/{name}">I lost it :-(</a>
+				<a href="{$webRoot}/items/{name}/del">I lost it :-(</a>
 			</p>
 		</div>
 	</xsl:template>

=== modified file 'src/core/ActionMapping.php'
--- src/core/ActionMapping.php	2008-10-20 16:41:10 +0000
+++ src/core/ActionMapping.php	2009-05-10 13:27:03 +0000
@@ -2,12 +2,8 @@
 /**
  * This class encapsulates information about the mapping to an action.
  * 
- * The mapping to an action consists of the name of an <code>ActionHandler</code> class and an
- * action within that handler. The path to the action is also availible to ease comparing mappings
- * (i.e. for interceptors). Finally, this class also flags whether the action mapping described by
- * the instance is considered valid. A valid action mapping may be invoked.
- * 
- * @version		$Id: ActionMapping.php 1478 2008-04-02 14:04:52Z anders $
+ * The mapping to an action consists of the name of the action class and the action method
+ * within that class.
  */
 class ActionMapping {
 	/**
@@ -23,19 +19,21 @@
 	private $actionMethod;
 
 	/**
-	 * Full path to the action class. (Do we care about this?)
+	 * Full path to the action class.
+	 * XXX: This is currently unused except when requiring the file. Do we care about this?
 	 * @var string
 	 */
 	private $actionPath;
 	
 	/**
-	 * Creates an instance of this class. An exception is thrown if the action method is not callable.
+	 * Creates an instance of this class. An exception is thrown if the action method is not
+	 * callable.
 	 *
 	 * @param string $actionPath	Full path to the action class.
-	 * @param string $actionMethod	Name of the action method in the action class. One of doGet or doPost.
+	 * @param string $actionMethod	Name of the action method in the action class.
 	 */
 	public function __construct ($actionPath, $actionMethod) {
-		require_once($actionPath);
+		require($actionPath);
 		$this->actionPath	= $actionPath;
 		$this->actionClass	= basename($actionPath, '.php');
 		$this->actionMethod	= $actionMethod;

=== modified file 'src/core/DefaultActionMapper.php'
--- src/core/DefaultActionMapper.php	2009-04-16 22:03:20 +0000
+++ src/core/DefaultActionMapper.php	2009-05-13 09:22:55 +0000
@@ -4,13 +4,20 @@
 /**
  * This class is responsible for mapping a request URI to a target action.
  * 
- * This implementation maps the
- * request URI to an action by first traversing the URI parts (the strings between the / characters)
- * and looking for an action handler ........
- * 
- * TODO: Complete comment
- * 
- * @version		$Id: DefaultActionMapper.php 1530 2008-07-21 15:10:08Z anders $
+ * This implementation maps the URI to an action by traversing the URI parts (the strings
+ * between the / characters) and searching the application's /actions directory for matching
+ * directories and files. Parts not explicitly matched are assumed to be IDs unless we have
+ * already set an ID for the current URI "section". URI parts after the part that matched
+ * a file are also mapped to parameters. The request method determines the actual method
+ * to call on the action class, either <code>doGet()</code> or <code>doPost()</code>. This is
+ * all easier to explain with a couple of examples:
+ *
+ *   Given the URI: /lists
+ *   Matches the action class: Lists.php <em>or</em> /lists/ListsIndex.php
+ *
+ *   Given the URI: /lists/1/contacts/4
+ *   Matches the action class: /lists/contacts/ListsContactsView.php
+ *   And sets request parameters: listsid=1 <em>and</em> contactsid=4
  */	
 class DefaultActionMapper {
 	/**
@@ -26,6 +33,12 @@
 	protected $mapping;
 
 	/**
+	 * Parts of the URI which we map into request parameters are temporarily stored here.
+	 * @var array
+	 */
+	protected $params;
+
+	/**
 	 * Create an instance of this class and initialize an <code>ActionMapping</code>.
 	 */
 	public function __construct ($request) {
@@ -36,30 +49,25 @@
 	 * Maps the request to its target action and returns the <code>ActionMapping</code>.
 	 * If the request could not be mapped to an action, <code>NULL</code> is returned.
 	 *
-	 * @return object	<code>ActionMapping</code> representing the action mapped to the request,
-	 * 					or <code>NULL</code> if no action could be mapped.
+	 * @return object <code>ActionMapping</code> representing the action mapped to the
+	 *                request, or <code>null</code> if no action could be mapped.
 	 */
 	public function map () {
-		/*
-		 * First strip scheme and hostname from webRoot, to ensure webRoot is an URI path like
-		 * the request URI. We then "normalize" the URI to be within the webRoot.
-		 */
-		$absoluteUri = parse_url(Config::get('webRoot'), PHP_URL_PATH);
-		$uri = trim(str_replace($absoluteUri, '', $this->request->getUri()), '/');
+		// Remove any prepending / and explode URI into its parts
+		$uri = ltrim($this->request->getUri());
 		$uriParts = (empty($uri) ? array() : explode('/', $uri));
 
 		// Map the URI to its target action
 		$actionPath = $this->mapAction($uriParts);
 
 		/*
-		 * If actionPath is not null, then the URI matched a PHP file. We can then map the action method
-		 * and parameters and create the action mapping.
+		 * If actionPath is not null, then the URI matched a PHP file. We can then map the
+		 * action method and parameters and create the action mapping.
 		 */
 		if ($actionPath !== null) {
 			$actionMethod = $this->mapMethod($this->request->getMethod());
 			$this->mapping = new ActionMapping($actionPath, $actionMethod);
 			$this->mapParams($uriParts);
-
 			return $this->mapping;
 		}
 
@@ -71,8 +79,8 @@
 	 * Maps the request method to an action method. We currently only support GET and POST.
 	 *
 	 * @param string $method	Request method.
+	 * @return string			Action method to use.
 	 * @throws Exception		If the request method is unsupported.
-	 * @return string			Action method to use.
 	 */
 	protected function mapMethod ($method) {
 		if ($method == 'GET') {
@@ -87,49 +95,72 @@
 	}
 
 	/**
-	 * Maps the action handler to handle the request specified by the supplied URI.
+	 * Maps the action class to handle the request specified by the supplied URI.
 	 * 
 	 * @param array $uri	The request URI in an array.
-	 * @return string		The full file system path to the action which is to handle the
-	 * 						request.
+	 * @return string		The absolute file system path to the action which is to handle the
+	 *                      request.
 	 */
 	protected function mapAction (&$uri) {
-		// Get the initial path to the handlers directory
-		$actionPath = ACTIONS_PATH . '/';
-		$index = 0;
+		$actionClassPath = ACTIONS_PATH . '/';
+		$actionClass = '';
+		$previousPart = '';
 
 		// Loop through the URI parts and look for a suitable action
 		foreach ($uri as $part) {
-			if ($part != '' && is_dir($actionPath . $part)) {
-				// Current part is a directory, append to action path and shift directory off the URI
-				$actionPath .= $part . '/';
+			// "CamelCase"-fix for action class name
+			$cameledPart = implode(array_map('ucfirst', explode('_', $part)));
+
+			// Check if the classpath + part is a directory
+			if ($part != '' && is_dir($actionClassPath . $part)) {
+				$actionClassPath .= $part . '/';
+				$actionClass .= $cameledPart;
+				$previousPart = $part; // Remember this part for next iteration
+
 				array_shift($uri);
 			}
 			else {
-				// "CamelCase"-fix for action name.
-				$actionName = implode(array_map('ucfirst', explode('_', $part)));
-				$actionFile = $actionName . '.php';
+				// For convenience, fix basename of possible action file at this point
+				$actionFile = $actionClass . $cameledPart . '.php';
 
-				if (is_file($actionPath . $actionFile)) {
-					// Element is specific action. Append element to action path.
-					$actionPath .= $actionFile;
+				// Check if the classpath + part is a file
+				if (is_file($actionClassPath . $actionFile)) {
+					$actionClassPath .= $actionFile;
+					$actionClass .= $cameledPart;
 					
-					// Shift the action name off URI
-					array_shift($uri);
-					return $actionPath;
-				}
-
-				break; // Current part was not found as action, break out of loop to try default action
+					array_shift($uri);
+
+					// This part did indeed lead to a specific file, so we return with
+					return $actionClassPath;
+				}
+				/*
+				 * Else if a previous part has been set, that means that the previous part
+				 * matched a directory, and we can set the current as an id related to that
+				 * "section".
+				 */
+				else if ($previousPart !== null) {
+					$this->params[$previousPart . 'id'] = $part;
+
+					// Reset previous part, to disallow several ids for same "section"
+					$previousPart = null;
+					array_shift($uri);
+				}
+				/*
+				 * Otherwise, the current part could not be matched or set as an id, so we
+				 * break out of loop to try a default action.
+				 */
+				else break;
 			}
 		}
 
 		/*
-		 * We are here if the latest URI part that was matched is an existing directory. Check to see
-		 * if a default action within that directory exists.
+		 * We are here if the URI didn't match a specific action class file. If no "previous
+		 * part" is present, it means we have an id of which to view a single "item", otherwise
+		 * no specific "item" is requested and we look for an index action.
 		 */
-		$actionPath .= 'Index.php';
-		if (is_file($actionPath)) {
-			return $actionPath;
+		$actionClassPath .= $actionClass . ($previousPart === null ? 'View.php' : 'Index.php');
+		if (is_file($actionClassPath)) {
+			return $actionClassPath;
 		}
 
 		return null;
@@ -138,27 +169,25 @@
 	/**
 	 * Maps any URI-specified parameters to the request.
 	 * 
-	 * @param array $uri	The remaining elements of the request URI (action has been sliced away),
-	 *						specifying the parameters to put in the request.
+	 * @param array $uri	The remaining elements of the request URI (action has been sliced
+	 *                      away), specifying the parameters to put in the request.
 	 */
 	protected function mapParams ($uri) {
-		$params = array();
-
 		// Step through parameters in the URI, two params at a time (as we map key/value pairs)
 		for ($i = 0; $i < count($uri); $i += 2) {
 			if (isset($uri[$i + 1])) {
 				// There is a next param, take current as key and next as value
-				$params[urldecode($uri[$i])] = urldecode($uri[$i + 1]);
+				$this->params[urldecode($uri[$i])] = urldecode($uri[$i + 1]);
 			}
 			else {
 				// No next param availible, use the current param as a value with "id" as key
-				$params['id'] = urldecode($uri[$i]);
+				$this->params['id'] = urldecode($uri[$i]);
 			}
 		}
 
-		if (!empty($params)) {
+		if (!empty($this->params)) {
 			// TODO: Do we want URI-params to overwrite GET-params as now, or not?
-			$this->request->putAll($params);
+			$this->request->putAll($this->params);
 		}
 	}
 }

=== modified file 'src/core/Dispatcher.php'
--- src/core/Dispatcher.php	2009-04-15 22:31:30 +0000
+++ src/core/Dispatcher.php	2009-05-13 14:49:34 +0000
@@ -73,7 +73,7 @@
 		}
 
 		$class = $actionMapping->getActionClass();
-		$this->action = new $class();
+		$this->action = new $class($this->request);
 		$this->stack = InterceptorFactory::createInterceptors($stack);
 	}
 	

=== modified file 'src/core/Request.php'
--- src/core/Request.php	2009-04-20 16:03:03 +0000
+++ src/core/Request.php	2009-05-13 09:22:55 +0000
@@ -44,12 +44,20 @@
 		
 		// If $uri is empty initialize this request with the URI from the client request
 		if (empty($uri)) {
-			$uri = $_SERVER['REQUEST_URI'];
-			
+            /*
+             * First strip scheme and hostname from webRoot, to ensure webRoot is an URI path
+             * like the request URI. We then "normalize" the URI to be within the webRoot.
+             */
+            $absoluteUri = parse_url(Config::get('webRoot'), PHP_URL_PATH);
+            $uri = str_replace($absoluteUri, '', $_SERVER['REQUEST_URI']);
+
 			// Strip any ?-type GET parameters from the URI (they are in the parameters)
 			if (($paramPos = strpos($uri, '?')) !== false) {
 				$uri = substr($uri, 0, $paramPos);
 			}
+
+            // Strip ending / to ensure URIs with or without are handles alike
+		    $uri = rtrim($uri, '/');
 		}
 		
 		// We use rawurldecode() instead of urldecode() to preserve + in URI

=== modified file 'src/database/ObjectBuilder.php'
--- src/database/ObjectBuilder.php	2009-04-03 16:00:20 +0000
+++ src/database/ObjectBuilder.php	2009-05-18 15:15:52 +0000
@@ -298,8 +298,12 @@
 		}
 
 		if ($isPopulated) {
-			// Set the "original"-property to PK value so we know this object can be UPDATEd
+			/*
+			 * Set the "original"-property to PK value so we know this object can be UPDATEd,
+			 * but in a non-dirty state unless modified.
+			 */
 			$object->original = $row[$this->primaryKeys[$objClass]];
+			$object->isDirty = false;
 		}
 
 		return $isPopulated;

=== modified file 'src/interceptors/AuthInterceptor.php'
--- src/interceptors/AuthInterceptor.php	2009-04-15 22:31:30 +0000
+++ src/interceptors/AuthInterceptor.php	2009-06-02 12:11:36 +0000
@@ -43,13 +43,12 @@
 			$user = $request->session[$this->userKey];
 
 			if (!$this->isUserAuthenticated($user)) {
-				$request->session['target'] = $request->getUri();
 				if ($action instanceof MessageAware) {
 					$action->msg->setMessage('You must log in to access
 							the page you requested.', false);
 				}
 
-				return $this->denyAccess();
+				return $this->denyAccess($request->getUri());
 			}
 
 			if ($action instanceof AuthAware) {
@@ -89,9 +88,17 @@
 
 	/**
 	 * Denies access by redirecting to the configured login URI.
+	 *
+	 * @param string $target Optional target of the request; the resource where access was
+	 *                       denied.
 	 */
-	private function denyAccess () {
-		return new RedirectResponse($this->loginUri);
+	private function denyAccess ($target = null) {
+		$redirectTo = $this->loginUri;
+		if (!empty($target)) {
+			$redirectTo .= "?target=$target";
+		}
+
+		return new RedirectResponse($redirectTo);
 	}
 }
 ?>

=== modified file 'src/interceptors/ModelInterceptor.php'
--- src/interceptors/ModelInterceptor.php	2009-04-14 16:50:50 +0000
+++ src/interceptors/ModelInterceptor.php	2009-05-18 15:15:52 +0000
@@ -51,6 +51,15 @@
 				if (method_exists($action, 'getModel')) {
 					// The action supplies an already instantiated model
 					$model = $action->getModel();
+
+					/*
+					 * During population of the model, we perform a property_exists() to
+					 * ensure that a parameter is indeed a property on the model. For this
+					 * to work the model must be a plain model; so we extract any proxy.
+					 */
+					if ($model instanceof ModelProxy) {
+						$model = $model->extract();
+					}
 				}
 				else {
 					// The action supplies model class name(s), so we must instantiate
@@ -68,8 +77,12 @@
 							$this->populate($model, $exploded, $value);
 						}
 						else {
+							/*
+							 * "original" is usually not actually defined, but we still want
+							 * it set when availible.
+							 */
 							if (property_exists($model, $param) || $param == 'original') {
-								$model->$param = $this->convertType($value);
+								$this->setProperty($model, $param, $value);
 							}
 						}
 					}
@@ -84,11 +97,12 @@
 	}
 
 	/**
-	 * Instantiates the model as specified by the action and passed to this method. The model name specified
-	 * may either be a single string with the model name, or an array structure where the main model
-	 * contains other models.
+	 * Instantiates the model as specified by the action and passed to this method. The model
+	 * name specified may either be a single string with the model name, or an array structure
+	 * where the main model contains other models.
 	 *
-	 * @param array $structure	Model name (along with any inner model structure) to instantiate.
+	 * @param array $structure Model name (along with any inner model structure) to
+	 *                         instantiate.
 	 * @return object
 	 */
 	private function instantiateModel ($structure) {
@@ -96,7 +110,10 @@
 			$model = new $structure();
 		}
 		else if (is_array($structure)) {		
-			// With an array structure for models, the first array element must be the main model class
+			/*
+			 * With an array structure for models, the first array element must be the main
+			 * model class.
+			 */
 			$mainModel = array_shift($structure);
 			$model = new $mainModel();
 
@@ -122,17 +139,16 @@
 		}
 		else return null;
 		
-		$model->isDirty = true;
 		return $model;
 	}
 
 	/**
-	 * Populates a specific property of a model with a value. The property is a <em>property path</em>
-	 * of the form <code>outerProperty::innerProperty</code> in which case <code>outerProperty</code> in
-	 * the model must be another model with the an <code>innerProperty</code> property to be populated
-	 * with the value.
+	 * Populates a specific property of a model with a value. The property is a
+	 * <em>property path</em> of the form <code>outerProperty::innerProperty</code> in which
+	 * case <code>outerProperty</code> in the model must be another model with the an
+	 * <code>innerProperty</code> property to be populated with the value.
 	 * 
-	 * TODO: This must be better documented and possibly add property_exists()-checks
+	 * TODO: This must be better documented internally.
 	 *
 	 * @param object $model		Model object to populate.
 	 * @param string $property	Property to populate.
@@ -164,15 +180,25 @@
 					}
 					break;
 				}
+
+				$this->setProperty($model, $currentProp, $value);
 			}
-
-			$model->$currentProp = $this->convertType($value);
-		}
-	}
-
-	/**
-	 * Converts textual values from the input to actual PHP types. Currently booleans and empty strings to
-	 * nulls are implemented.
+		}
+	}
+
+	/**
+	 * Sets a property value on the model, if the value is different from the current value.
+	 */
+	private function setProperty ($model, $property, $value) {
+		if ($model->$property !== $value) {
+			$model->$property = $value;
+			$model->isDirty = true;
+		}
+	}
+	
+	/**
+	 * Converts textual values from the input to actual PHP types. Currently booleans and empty
+	 * strings to nulls are implemented.
 	 * 
 	 * @param string $value		Value from input.
 	 * @return mixed			Converted value.

=== modified file 'src/models/ModelProxy.php'
--- src/models/ModelProxy.php	2009-04-12 01:09:55 +0000
+++ src/models/ModelProxy.php	2009-05-18 15:15:52 +0000
@@ -184,7 +184,7 @@
 			if (property_exists($model, $name)) {
 				if ($model->$name !== $value) {
 					$model->$name = $value;
-					$this->modelChanged($model);
+					$this->modelChanged($model, $value);
 				}
 			}
 		}
@@ -238,7 +238,7 @@
 		if (is_object($value)) {
 			if ($offset !== null) {
 				$this->models[$offset] = $value;
-				$this->modelChanged($this->models[$offset]);
+				$this->modelChanged($this->models[$offset], $value);
 			}
 			else {
 				$this->models[] = $value;
@@ -315,10 +315,17 @@
 	/**
 	 * Flag the model as dirty, as changes have been made to its state.
 	 *
-	 * @param object $model	The model whose state has changed.
+	 * @param object $model   The model whose state has changed.
+	 * @param mixed $newValue Optional value that was set on a property of the model. If
+	 *                        <code>NULL</code>, an object or an array we assume proxifying
+	 *                        inner models may be required.
 	 */
-	protected function modelChanged ($model) {
+	protected function modelChanged ($model, $newValue = null) {
 		$model->isDirty = true;
+		
+		if ($newValue === null || is_array($newValue) || is_object($newValue)) {
+			$this->isInnerProxied = false;
+		}
 	}
 	
 	/**
@@ -339,9 +346,9 @@
 	}
 	
 	/**
-	 * Saves the supplied model by calling the <code>update()</code> DAO method if the model isn't
-	 * new and changes have been made on its data, or the <code>insert()</code> DAO method if it's
-	 * new.
+	 * Saves the supplied model by calling the <code>update()</code> DAO method if the model
+	 * isn't new and changes have been made on its data, or the <code>insert()</code> DAO
+	 * method if it's new.
 	 *
 	 * @param object $model	The model to save.
 	 * @return int			Number of rows affected in the database.
@@ -350,7 +357,14 @@
 		$numAffected = 0;
 		
 		if (!empty($model->original)) {
-			if (property_exists($model, 'isDirty') && $model->isDirty) {
+			/*
+			 * We assume the model is dirty unless explicitly flagged as non-dirty. This is a
+			 * change from earlier Kolibri snapshots where we assumed it was NOT dirty without
+			 * the flag. This was changed based on the fact that we sometimes instantiate our
+			 * own plain models, whereby we had to manually set isDirty = true if we were
+			 * editing, which is not something the user should be concerned about.
+			 */
+			if (!property_exists($model, 'isDirty') || $model->isDirty) {
 				 $numAffected = $this->objects->update($model);
 			}
 		}

=== modified file 'src/models/Models.php'
--- src/models/Models.php	2008-12-03 02:22:07 +0000
+++ src/models/Models.php	2009-05-13 20:22:32 +0000
@@ -42,8 +42,8 @@
 			return new ModelProxy($model);
 		}
 
-		// Unsupported object type, simply return it
-		return $model;
+		// Unsupported object type, return null
+		return null;
 	}
 }
 ?>

=== modified file 'src/models/ValidateableModelProxy.php'
--- src/models/ValidateableModelProxy.php	2009-04-04 18:03:16 +0000
+++ src/models/ValidateableModelProxy.php	2009-05-13 20:22:32 +0000
@@ -4,12 +4,12 @@
  * models in order to add support for validation.
  */
 class ValidateableModelProxy extends ModelProxy {
-	
+
 	/**
 	 * @var Validator
 	 */
 	private $validator;
-	
+
 	/**
 	 * Creates a <code>ValidateableModelProxy</code> instance for the model supplied. It is assumed
 	 * that the model has been verified <code>Validateable</code>.
@@ -19,15 +19,15 @@
 	public function __construct ($model) {
 		parent::__construct($model);
 	}
-	
+
 	/**
 	 * Overrides ModelProxy::save() by making sure contained models are valid before they
 	 * are saved. Contained models that have already been validated are not validated again.
-	 * 
+	 *
 	 * @return mixed	Number of saved rows in the database, or <code>false</code> if a
 	 *					a model contains invalid data or a preSave() method on a model returned
 	 *					false.
-	 * 
+	 *
 	 */
 	public function save () {
 		if (!$this->validate()) {
@@ -35,18 +35,18 @@
 		}
 		return parent::save();
 	}
-	
+
 	/**
 	 * Validates the contained models and returns <code>true</code> if all are valid or
 	 * <code>false</code> if one or more are invalid.
-	 * 
+	 *
 	 * @return bool		<code>true</code> if all models are valid, <code>false</code> if not.
 	 */
 	public function validate () {
 		$this->proxifyInnerModels();
 		$this->initValidator();
 		$isValid = true; // We start out with valid state
-		
+
 		foreach ($this->models as $model) {
 			// And set invalid for invalid objects, but never again valid
 			$isValid = ($this->validateModel($model) ? $isValid : false);
@@ -57,23 +57,23 @@
 				}
 			}
 		}
-		
+
 		return $isValid;
 	}
-	
+
 	/**
 	 * Alias of validate() to accommodate for more readable code (i.e. for tests).
-	 * 
+	 *
 	 * @return bool
 	 */
 	public function isValid () {
 		return $this->validate();
 	}
-	
+
 	/**
 	 * Validates the supplied model. If the model has already been validated (and is unchanged
 	 * since) its previous result is returned, else the Validator is invoked to validate the model.
-	 * 
+	 *
 	 * @param object $model	The model to validate.
 	 * @return bool
 	 */
@@ -83,18 +83,21 @@
 		}
 		return $this->validator->validate($model);
 	}
-	
+
 	/**
 	 * Remove validated flag, as changes have been made to its state and it's unknown whether it
 	 * is valid or not.
 	 *
-	 * @param object $model	The model whose state has changed.
+	 * @param object $model   The model whose state has changed.
+	 * @param mixed $newValue Optional value that was set on a property of the model. If
+	 *                        <code>NULL</code>, an object or an array we assume proxifying
+	 *                        inner models may be required.
 	 */
-	protected function modelChanged ($model) {
-		parent::modelChanged($model);
+	protected function modelChanged ($model, $newValue = null) {
+		parent::modelChanged($model, $newValue);
 		unset($model->isValid);
 	}
-	
+
 	/**
 	 * Initialized the validator if not already initialized.
 	 */

=== modified file 'src/specs/KolibriContext.php'
--- src/specs/KolibriContext.php	2009-04-29 10:37:01 +0000
+++ src/specs/KolibriContext.php	2009-06-02 12:11:36 +0000
@@ -1,81 +1,131 @@
 <?php
 /**
- * This class is the Kolibri Test framework. It serves as a class for Model, Action and View
- * testing. Right now it support Action and Model testing. You have to extend KolibriContext
- * to use this test-framework. It reflects the same methods as PHPSpec_Context has, but they
- * are named differently. The corresponding method names are setup(), preSpec(), postSpec()
- * and tearDown().
+ * This class is the Kolibri Test framework.
+ *
+ * It serves as a class for model, action and integration testing. To use this test framework,
+ * simply extend KolibriContext and include SpecHelper. This class reflects the same methods
+ * as PHPSPec_Context, but they are named differently. The corresponding method names are
+ * setUp(), preSpec(), postSpec() and tearDown().
  */
 class KolibriContext extends PHPSpec_Context {
 	/**
 	 * Holds the fixtures loaded, only populated during model testing.
 	 * @var Fixtures
 	 */
-    public $fixtures;
+    protected $fixtures;
 
 	/**
 	 * Name of the model we are testing, if we are model testing.
 	 * @var string
 	 */
-    public $modelName;
+    protected $modelName;
+
+	/**
+	 * The browser through Selenium during integration testing.
+	 * @var Testing_Selenium
+	 */
+	protected $browser;
 
 	/**
 	 * A reference to the database connection.
 	 * @var DatabaseConnection
 	 */
-    private $db;
+    protected $db;
 
 	/**
 	 * The current test type, corresponding to one of the class constants.
 	 * @var string
 	 */
-	private $testType;
-	
-	const ACTION_TEST = 'action';
-	const VIEW_TEST   = 'view';
-	const MODEL_TEST  = 'model';
-	
-    /**
-     * Executes before all spec methods are invoked, and triggers the setup() method if
-	 * present. Distinguishes between model, action and view testing. It also establishes a
-	 * database connection which is used to roll back any changes between each spec.
-     */
-    public function beforeAll () {
+	protected $testType;
+
+	// Constants used to identify type of testing
+	const ACTION_TEST      = 'action';
+	const INTEGRATION_TEST = 'integration';
+	const MODEL_TEST       = 'model';
+
+	/**
+	 * Internal initialization method which figures out what type of testing is currently being
+	 * done; either model, action or integration testing. It also establishes a database
+	 * connection which is used to begin/rollback any changes between each spec.
+	 */
+	protected function init () {
 		if (Config::getMode() != Config::TEST) {
 			throw new Exception('KolibriTestCase requires that the current KOLIBRI_MODE is set
 					to TEST.');
 		}
-		
+
+		// Figure out the filename of the current class
         $className = get_class($this);
+		$reflection = new ReflectionClass($className);
+		$classFilename = $reflection->getFileName();
 		
-        if (substr(strtolower($className), -5) == self::MODEL_TEST) {
+		// Figure out which "testing type directory" we are within
+		do {
+			$currentPath = (isset($currentPath) ? $currentPath . '/..' : $classFilename);
+			$mainDir = basename(dirname(realpath($currentPath)));
+			$parentDir = basename(dirname(realpath($currentPath . '/..')));
+		} while (
+				$parentDir !== 'specs' &&
+				$parentDir !== 'actions' &&
+				$parentDir !== 'models' &&
+				$parentDir !== 'integration'
+			);
+
+		// If class name contains 'model' or we are within the models dir; model testing
+        if (($inName = substr(strtolower($className), -5)) == self::MODEL_TEST
+				|| $parentDir == 'models') {
 			$this->testType = self::MODEL_TEST;
-            $this->modelName = substr($className, 8, -5);
-			$this->fixtures = new Fixtures($this->modelName);
+			if ($inName) {
+	            $this->modelName = substr($className, 8, -5);
+			}
+			else $this->modelName = ucfirst($mainDir);
         }
-        elseif (substr(strtolower($className), -6) == self::ACTION_TEST) {
+		// Else if class name contains 'action' or we are within actions dir; action testing
+        else if ($mainDir == 'actions'
+				|| $parentDir == 'actions'
+				|| substr(strtolower($className), -6) == self::ACTION_TEST) {
             $this->testType = self::ACTION_TEST;
         }
-        elseif (substr(strtolower($className), -4) == self::VIEW_TEST) {
-			$this->testType = self::VIEW_TEST;
-            throw new Exception('KolibriContext does not support view testing yet.');
+		// Else if we are within the integration dir; integration testing
+        else if ($mainDir == 'integration' || $parentDir == 'integration') {
+			$this->testType = self::INTEGRATION_TEST;
+			$this->browser = self::getBrowserInstance();
+
+			/*
+			 * We register a shutdown function to stop the browser after the very last
+			 * test -- we don't want to start/stop the browser all the time.
+			 */
+			register_shutdown_function(array('KolibriContext', 'stopBrowserInstance'));
         }
         else {
-            throw new Exception('KolibriTestCase needs to have either Model, Action or View
-					in the end of the class name.');
+            throw new Exception('KolibriContext classes must be within one of the directories
+					specs/actions, specs/integration or specs/models.');
         }
-        
+
 		$this->db = DatabaseFactory::getConnection();
-		if (method_exists($this, 'setup')) {
-			$this->setup();
+	}
+
+    /**
+     * Executes before all spec methods are invoked, and triggers the setUp() method if
+	 * present.
+     */
+    public function beforeAll () {
+		$this->init();
+		if (method_exists($this, 'setUp')) {
+			$this->setUp();
 		}
     }
-	
+
 	/**
-	 * Triggers the preSpec() method for doing something _before_ a spec has been invoked. 
+	 * Starts a new database transaction before each spec and refreshes any fixtures for
+	 * models specs. Also triggers a preSpec() method, if defined, for doing something
+	 * _before_ a spec has been invoked.
 	 */
     public function before () {
 		$this->db->begin();
+		if ($this->testType == self::MODEL_TEST) {
+			$this->fixtures = new Fixtures($this->modelName);
+		}
 		if (method_exists($this, 'preSpec')) {
 			$this->preSpec();
 		}
@@ -91,9 +141,9 @@
 		}
 		$this->db->rollback();
     }
-    
+
 	/**
-	 * Triggers the tearDown() method for doing something _after all_ specs has runned. 
+	 * Triggers the tearDown() method for doing something _after all_ specs has runned.
 	 */
     public function afterAll () {
 		if (method_exists($this, 'tearDown')) {
@@ -112,7 +162,7 @@
 			ob_flush();
 		}
     }
-	
+
 	/**
 	 * Fires a GET request for testing an action. Any supplied parameteres and session
 	 * data are passed along to the request. This provides access to the <code>$request</code>,
@@ -161,7 +211,7 @@
 		$_SERVER['REQUEST_URI'] = $uri;
 		$_SESSION = ($session !== null ? $session : array());
 	}
-	
+
 	/**
 	 * Does not allow you to use post and/or get in any other testing classes than action.
 	 *
@@ -176,5 +226,29 @@
 		return true;
 	}
 
+	/**
+	 * Returns a Selenium browser instance, creating one if it doesn't already exist (unless
+	 * $createAnyway is false).
+	 */
+	private static function getBrowserInstance ($createAnyway = true) {
+		static $browser;
+		if (!isset($browser) && $createAnyway) {
+			require('Testing/Selenium.php');
+			$browser = new Testing_Selenium("*firefox", Config::get('webRoot'));
+			$browser->start();
+		}
+		return $browser;
+	}
+
+	/**
+	 * Stops the current browser instance if present. Should not be called explicitly; it will
+	 * be called implicitly at the very end of script execution.
+	 */
+	public static function stopBrowserInstance () {
+		$browser = self::getBrowserInstance(false);
+		if ($browser !== null) {
+			$browser->stop();
+		}
+	}
 }
 ?>

=== modified file 'src/validation/ExistsValidator.php'
--- src/validation/ExistsValidator.php	2008-10-20 16:41:10 +0000
+++ src/validation/ExistsValidator.php	2009-05-13 20:22:32 +0000
@@ -8,7 +8,8 @@
 
 	public function validate ($property, $rules) {
 		if (!isset($rules['condition'])) {
-			if ($this->model->$property === null || $this->model->$property == '') {
+			if ($this->model->$property === null || $this->model->$property === ''
+					|| $this->model->$property === array()) {
 				return array('exists' => $rules['name']);
 			}
 		}


Follow ups