Browse Source

Merge branch 'v0.9/transaction-multiexec-state'

Daniele Alessandri 11 years ago
parent
commit
0a60a156f0

+ 7 - 0
CHANGELOG.md

@@ -20,6 +20,13 @@ v0.9.0 (201x-xx-xx)
   using objects that responds to `__invoke()` (not all the kinds of callables)
   even for custom options defined by the user.
 
+- Changed a couple of options for our transaction abstraction:
+
+    - `exceptions`: overrides the value of the client option with the same name.
+      Please note that it does not affect all the transaction control commands
+      such as `MULTI`, `EXEC`, `DISCARD`, `WATCH` and `UNWATCH`.
+    - `on_retry`: this option has been removed.
+
 - Removed pipeline executors, now command pipelines can be easily customized by
   extending the standard `Predis\Pipeline\Pipeline` class. Accepted options when
   creating a pipeline using `Predis\Client::pipeline()` are:

+ 126 - 143
lib/Predis/Transaction/MultiExec.php

@@ -11,6 +11,7 @@
 
 namespace Predis\Transaction;
 
+use InvalidArgumentException;
 use SplQueue;
 use Predis\BasicClientInterface;
 use Predis\ClientException;
@@ -30,19 +31,14 @@ use Predis\Protocol\ProtocolException;
  */
 class MultiExec implements BasicClientInterface, ExecutableContextInterface
 {
-    const STATE_RESET       = 0;    // 0b00000
-    const STATE_INITIALIZED = 1;    // 0b00001
-    const STATE_INSIDEBLOCK = 2;    // 0b00010
-    const STATE_DISCARDED   = 4;    // 0b00100
-    const STATE_CAS         = 8;    // 0b01000
-    const STATE_WATCH       = 16;   // 0b10000
-
     private $state;
-    private $canWatch;
 
     protected $client;
-    protected $options;
     protected $commands;
+    protected $exceptions = true;
+    protected $attempts   = 0;
+    protected $watchKeys  = array();
+    protected $modeCAS    = false;
 
     /**
      * @param ClientInterface $client Client instance used by the transaction.
@@ -50,61 +46,13 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     public function __construct(ClientInterface $client, array $options = null)
     {
-        $this->checkCapabilities($client);
-        $this->options = $options ?: array();
-        $this->client = $client;
-        $this->reset();
-    }
-
-    /**
-     * Sets the internal state flags.
-     *
-     * @param int $flags Set of flags
-     */
-    protected function setState($flags)
-    {
-        $this->state = $flags;
-    }
-
-    /**
-     * Gets the internal state flags.
-     *
-     * @return int
-     */
-    protected function getState()
-    {
-        return $this->state;
-    }
-
-    /**
-     * Sets one or more flags.
-     *
-     * @param int $flags Set of flags
-     */
-    protected function flagState($flags)
-    {
-        $this->state |= $flags;
-    }
+        $this->preconditions($client);
+        $this->configure($client, $options ?: array());
 
-    /**
-     * Resets one or more flags.
-     *
-     * @param int $flags Set of flags
-     */
-    protected function unflagState($flags)
-    {
-        $this->state &= ~$flags;
-    }
+        $this->client = $client;
+        $this->state = new MultiExecState();
 
-    /**
-     * Checks is a flag is set.
-     *
-     * @param int $flags Flag
-     * @return Boolean
-     */
-    protected function checkState($flags)
-    {
-        return ($this->state & $flags) === $flags;
+        $this->reset();
     }
 
     /**
@@ -113,7 +61,7 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      *
      * @param ClientInterface $client Client instance used by the transaction object.
      */
-    private function checkCapabilities(ClientInterface $client)
+    private function preconditions(ClientInterface $client)
     {
         if ($client->getConnection() instanceof AggregatedConnectionInterface) {
             throw new NotSupportedException(
@@ -121,24 +69,37 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
             );
         }
 
-        $profile = $client->getProfile();
-
-        if ($profile->supportsCommands(array('multi', 'exec', 'discard')) === false) {
+        if (!$client->getProfile()->supportsCommands(array('multi', 'exec', 'discard'))) {
             throw new NotSupportedException(
                 'The current profile does not support MULTI, EXEC and DISCARD'
             );
         }
-
-        $this->canWatch = $profile->supportsCommands(array('watch', 'unwatch'));
     }
 
     /**
-     * Checks if WATCH and UNWATCH are supported by the server profile.
-     */
-    private function isWatchSupported()
+     * Configures the transaction using the provided options.
+     *
+     * @param ClientInterface $client Underlying client instance.
+     * @param array $options Array of options for the transaction.
+     **/
+    protected function configure(ClientInterface $client, array $options)
     {
-        if ($this->canWatch === false) {
-            throw new NotSupportedException('The current profile does not support WATCH and UNWATCH');
+        if (isset($options['exceptions'])) {
+            $this->exceptions = (bool) $options['exceptions'];
+        } else {
+            $this->exceptions = $client->getOptions()->exceptions;
+        }
+
+        if (isset($options['cas'])) {
+            $this->modeCAS = (bool) $options['cas'];
+        }
+
+        if (isset($options['watch']) && $keys = $options['watch']) {
+            $this->watchKeys = $keys;
+        }
+
+        if (isset($options['retry'])) {
+            $this->attempts = (int) $options['retry'];
         }
     }
 
@@ -147,7 +108,7 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     protected function reset()
     {
-        $this->setState(self::STATE_RESET);
+        $this->state->reset();
         $this->commands = new SplQueue();
     }
 
@@ -156,32 +117,31 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     protected function initialize()
     {
-        if ($this->checkState(self::STATE_INITIALIZED)) {
+        if ($this->state->isInitialized()) {
             return;
         }
 
-        $options = $this->options;
-
-        if (isset($options['cas']) && $options['cas']) {
-            $this->flagState(self::STATE_CAS);
+        if ($this->modeCAS) {
+            $this->state->flag(MultiExecState::CAS);
         }
-        if (isset($options['watch'])) {
-            $this->watch($options['watch']);
+
+        if ($this->watchKeys) {
+            $this->watch($this->watchKeys);
         }
 
-        $cas = $this->checkState(self::STATE_CAS);
-        $discarded = $this->checkState(self::STATE_DISCARDED);
+        $cas = $this->state->isCAS();
+        $discarded = $this->state->isDiscarded();
 
         if (!$cas || ($cas && $discarded)) {
-            $this->client->multi();
+            $this->call('multi');
 
             if ($discarded) {
-                $this->unflagState(self::STATE_CAS);
+                $this->state->unflag(MultiExecState::CAS);
             }
         }
 
-        $this->unflagState(self::STATE_DISCARDED);
-        $this->flagState(self::STATE_INITIALIZED);
+        $this->state->unflag(MultiExecState::DISCARDED);
+        $this->state->flag(MultiExecState::INITIALIZED);
     }
 
     /**
@@ -199,6 +159,25 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
         return $response;
     }
 
+    /**
+     * Sends a Redis command bypassing the transaction logic.
+     *
+     * @param string $method Command ID.
+     * @param array $arguments Arguments for the command.
+     * @return mixed
+     */
+    protected function call($commandID, $arguments = array())
+    {
+        $command  = $this->client->createCommand($commandID, $arguments);
+        $response = $this->client->executeCommand($command);
+
+        if ($response instanceof Response\Error) {
+            throw new Response\ServerException($response->getMessage());
+        }
+
+        return $response;
+    }
+
     /**
      * Executes the specified Redis command.
      *
@@ -210,12 +189,12 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
         $this->initialize();
         $response = $this->client->executeCommand($command);
 
-        if ($this->checkState(self::STATE_CAS)) {
+        if ($this->state->isCAS()) {
             return $response;
         }
 
         if (!$response instanceof Response\StatusQueued) {
-            $this->onProtocolError('The server did not respond with a QUEUED status reply');
+            $this->onProtocolError('The server did not respond with a QUEUED status response');
         }
 
         $this->commands->enqueue($command);
@@ -231,16 +210,19 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     public function watch($keys)
     {
-        $this->isWatchSupported();
+        if (!$this->client->getProfile()->supportsCommand('WATCH')) {
+            throw new NotSupportedException('WATCH is not supported by the current profile');
+        }
 
-        if ($this->checkState(self::STATE_INITIALIZED) && !$this->checkState(self::STATE_CAS)) {
+        if ($this->state->isWatchAllowed()) {
             throw new ClientException('WATCH after MULTI is not allowed');
         }
 
-        $reply = $this->client->watch($keys);
-        $this->flagState(self::STATE_WATCH);
+        $response = $this->call('watch', array($keys));
+
+        $this->state->flag(MultiExecState::WATCH);
 
-        return $reply;
+        return $response;
     }
 
     /**
@@ -250,9 +232,9 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     public function multi()
     {
-        if ($this->checkState(self::STATE_INITIALIZED | self::STATE_CAS)) {
-            $this->unflagState(self::STATE_CAS);
-            $this->client->multi();
+        if ($this->state->check(MultiExecState::INITIALIZED | MultiExecState::CAS)) {
+            $this->state->unflag(MultiExecState::CAS);
+            $this->call('multi');
         } else {
             $this->initialize();
         }
@@ -267,8 +249,11 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     public function unwatch()
     {
-        $this->isWatchSupported();
-        $this->unflagState(self::STATE_WATCH);
+        if (!$this->client->getProfile()->supportsCommand('WATCH')) {
+            throw new NotSupportedException('UNWATCH is not supported by the current profile');
+        }
+
+        $this->state->unflag(MultiExecState::WATCH);
         $this->__call('unwatch', array());
 
         return $this;
@@ -282,11 +267,11 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     public function discard()
     {
-        if ($this->checkState(self::STATE_INITIALIZED)) {
-            $command = $this->checkState(self::STATE_CAS) ? 'unwatch' : 'discard';
-            $this->client->$command();
+        if ($this->state->isInitialized()) {
+            $this->call($this->state->isCAS() ? 'unwatch' : 'discard');
+
             $this->reset();
-            $this->flagState(self::STATE_DISCARDED);
+            $this->state->flag(MultiExecState::DISCARDED);
         }
 
         return $this;
@@ -309,24 +294,30 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     private function checkBeforeExecution($callable)
     {
-        if ($this->checkState(self::STATE_INSIDEBLOCK)) {
-            throw new ClientException("Cannot invoke 'execute' or 'exec' inside an active client transaction block");
+        if ($this->state->isExecuting()) {
+            throw new ClientException(
+                'Cannot invoke "execute" or "exec" inside an active transaction context'
+            );
         }
 
         if ($callable) {
             if (!is_callable($callable)) {
-                throw new \InvalidArgumentException('Argument passed must be a callable object');
+                throw new InvalidArgumentException('Argument passed must be a callable object');
             }
 
             if (!$this->commands->isEmpty()) {
                 $this->discard();
-                throw new ClientException('Cannot execute a transaction block after using fluent interface');
-            }
-        }
 
-        if (isset($this->options['retry']) && !isset($callable)) {
+                throw new ClientException(
+                    'Cannot execute a transaction block after using fluent interface
+                ');
+            }
+        } else if ($this->attempts) {
             $this->discard();
-            throw new \InvalidArgumentException('Automatic retries can be used only when a transaction block is provided');
+
+            throw new InvalidArgumentException(
+                'Automatic retries can be used only when a callable block is provided'
+            );
         }
     }
 
@@ -340,65 +331,58 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
     {
         $this->checkBeforeExecution($callable);
 
-        $reply = null;
-        $values = array();
-        $attempts = isset($this->options['retry']) ? (int) $this->options['retry'] : 0;
+        $execResponse = null;
+        $attempts = $this->attempts;
 
         do {
-            if ($callable !== null) {
+            if ($callable) {
                 $this->executeTransactionBlock($callable);
             }
 
             if ($this->commands->isEmpty()) {
-                if ($this->checkState(self::STATE_WATCH)) {
+                if ($this->state->isWatching()) {
                     $this->discard();
                 }
 
                 return;
             }
 
-            $reply = $this->client->exec();
+            $execResponse = $this->call('exec');
 
-            if ($reply === null) {
+            if ($execResponse === null) {
                 if ($attempts === 0) {
-                    $message = 'The current transaction has been aborted by the server';
-                    throw new AbortedMultiExecException($this, $message);
+                    throw new AbortedMultiExecException(
+                        $this, 'The current transaction has been aborted by the server'
+                    );
                 }
 
                 $this->reset();
 
-                if (isset($this->options['on_retry']) && is_callable($this->options['on_retry'])) {
-                    call_user_func($this->options['on_retry'], $this, $attempts);
-                }
-
                 continue;
             }
 
             break;
         } while ($attempts-- > 0);
 
+        $response = array();
         $commands = $this->commands;
-        $size = count($reply);
+        $size = count($execResponse);
 
         if ($size !== count($commands)) {
-            $this->onProtocolError("EXEC returned an unexpected number of replies");
+            $this->onProtocolError('EXEC returned an unexpected number of response items');
         }
 
-        $clientOpts = $this->client->getOptions();
-        $useExceptions = isset($clientOpts->exceptions) ? $clientOpts->exceptions : true;
-
         for ($i = 0; $i < $size; $i++) {
-            $commandReply = $reply[$i];
+            $cmdResponse = $execResponse[$i];
 
-            if ($commandReply instanceof Response\ErrorInterface && $useExceptions) {
-                $message = $commandReply->getMessage();
-                throw new Response\ServerException($message);
+            if ($cmdResponse instanceof Response\ErrorInterface && $this->exceptions) {
+                throw new Response\ServerException($cmdResponse->getMessage());
             }
 
-            $values[$i] = $commands->dequeue()->parseResponse($commandReply);
+            $response[$i] = $commands->dequeue()->parseResponse($cmdResponse);
         }
 
-        return $values;
+        return $response;
     }
 
     /**
@@ -408,24 +392,23 @@ class MultiExec implements BasicClientInterface, ExecutableContextInterface
      */
     protected function executeTransactionBlock($callable)
     {
-        $blockException = null;
-        $this->flagState(self::STATE_INSIDEBLOCK);
+        $exception = null;
+        $this->state->flag(MultiExecState::INSIDEBLOCK);
 
         try {
             call_user_func($callable, $this);
         } catch (CommunicationException $exception) {
-            $blockException = $exception;
+            // NOOP
         } catch (Response\ServerException $exception) {
-            $blockException = $exception;
+            // NOOP
         } catch (\Exception $exception) {
-            $blockException = $exception;
             $this->discard();
         }
 
-        $this->unflagState(self::STATE_INSIDEBLOCK);
+        $this->state->unflag(MultiExecState::INSIDEBLOCK);
 
-        if ($blockException !== null) {
-            throw $blockException;
+        if ($exception) {
+            throw $exception;
         }
     }
 

+ 165 - 0
lib/Predis/Transaction/MultiExecState.php

@@ -0,0 +1,165 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Predis\Transaction;
+
+/**
+ * Utility class used to track the state of a MULTI / EXEC transaction.
+ *
+ * @author Daniele Alessandri <suppakilla@gmail.com>
+ */
+class MultiExecState
+{
+    const INITIALIZED = 1;    // 0b00001
+    const INSIDEBLOCK = 2;    // 0b00010
+    const DISCARDED   = 4;    // 0b00100
+    const CAS         = 8;    // 0b01000
+    const WATCH       = 16;   // 0b10000
+
+    private $flags;
+
+    /**
+     *
+     */
+    public function __construct()
+    {
+        $this->flags = 0;
+    }
+
+    /**
+     * Sets the internal state flags.
+     *
+     * @param int $flags Set of flags
+     */
+    public function set($flags)
+    {
+        $this->flags = $flags;
+    }
+
+    /**
+     * Gets the internal state flags.
+     *
+     * @return int
+     */
+    public function get()
+    {
+        return $this->flags;
+    }
+
+    /**
+     * Sets one or more flags.
+     *
+     * @param int $flags Set of flags
+     */
+    public function flag($flags)
+    {
+        $this->flags |= $flags;
+    }
+
+    /**
+     * Resets one or more flags.
+     *
+     * @param int $flags Set of flags
+     */
+    public function unflag($flags)
+    {
+        $this->flags &= ~$flags;
+    }
+
+    /**
+     * Returns if the specified flag or set of flags is set.
+     *
+     * @param int $flags Flag
+     * @return bool
+     */
+    public function check($flags)
+    {
+        return ($this->flags & $flags) === $flags;
+    }
+
+    /**
+     * Resets the state of a transaction.
+     */
+    public function reset()
+    {
+        $this->flags = 0;
+    }
+
+    /**
+     * Returns the state of the RESET flag.
+     *
+     * @return bool
+     */
+    public function isReset()
+    {
+        return $this->flags === 0;
+    }
+
+    /**
+     * Returns the state of the INITIALIZED flag.
+     *
+     * @return bool
+     */
+    public function isInitialized()
+    {
+        return $this->check(self::INITIALIZED);
+    }
+
+    /**
+     * Returns the state of the INSIDEBLOCK flag.
+     *
+     * @return bool
+     */
+    public function isExecuting()
+    {
+        return $this->check(self::INSIDEBLOCK);
+    }
+
+    /**
+     * Returns the state of the CAS flag.
+     *
+     * @return bool
+     */
+    public function isCAS()
+    {
+        return $this->check(self::CAS);
+    }
+
+    /**
+     * Returns if WATCH is allowed in the current state.
+     *
+     * @return bool
+     */
+    public function isWatchAllowed()
+    {
+        return $this->check(self::INITIALIZED) && !$this->check(self::CAS);
+    }
+
+    /**
+     * Returns the state of the WATCH flag.
+     *
+     * @return bool
+     */
+    public function isWatching()
+    {
+        return $this->check(self::WATCH);
+    }
+
+    /**
+     * Returns the state of the DISCARDED flag.
+     *
+     * @return bool
+     */
+    public function isDiscarded()
+    {
+        return $this->check(self::DISCARDED);
+    }
+}

+ 7 - 3
tests/Predis/ClientTest.php

@@ -666,6 +666,7 @@ class ClientTest extends StandardTestCase
 
     /**
      * @group disconnected
+     * @todo I hate this test but reflection is the easiest way in this case.
      */
     public function testTransactionWithArrayReturnsTransactionMultiExecWithOptions()
     {
@@ -675,10 +676,13 @@ class ClientTest extends StandardTestCase
 
         $this->assertInstanceOf('Predis\Transaction\MultiExec', $tx = $client->transaction($options));
 
-        $reflection = new \ReflectionProperty($tx, 'options');
-        $reflection->setAccessible(true);
+        $property = new \ReflectionProperty($tx, 'modeCAS');
+        $property->setAccessible(true);
+        $this->assertSame($options['cas'], $property->getValue($tx));
 
-        $this->assertSame($options, $reflection->getValue($tx));
+        $property = new \ReflectionProperty($tx, 'attempts');
+        $property->setAccessible(true);
+        $this->assertSame($options['retry'], $property->getValue($tx));
     }
 
     /**

+ 202 - 0
tests/Predis/Transaction/MultiExecStateTest.php

@@ -0,0 +1,202 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Predis\Transaction;
+
+use PHPUnit_Framework_TestCase as StandardTestCase;
+
+/**
+ * @group realm-transaction
+ */
+class MultiExecStateTest extends StandardTestCase
+{
+    /**
+     * @group disconnected
+     */
+    public function testFlagsValues()
+    {
+        $this->assertSame(1,  MultiExecState::INITIALIZED);
+        $this->assertSame(2,  MultiExecState::INSIDEBLOCK);
+        $this->assertSame(4,  MultiExecState::DISCARDED);
+        $this->assertSame(8,  MultiExecState::CAS);
+        $this->assertSame(16, MultiExecState::WATCH);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testStateConstructorStartsWithResetState()
+    {
+        $state = new MultiExecState();
+
+        $this->assertSame(0, $state->get());
+        $this->assertTrue($state->isReset());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCanCheckOneOrMoreStateFlags()
+    {
+        $flags = MultiExecState::INITIALIZED | MultiExecState::CAS;
+        $state = new MultiExecState();
+        $state->set($flags);
+
+        $this->assertSame($flags, $state->get());
+
+        $this->assertFalse($state->check(MultiExecState::INSIDEBLOCK));
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertTrue($state->check(MultiExecState::CAS));
+
+        $this->assertTrue($state->check($flags));
+        $this->assertFalse($state->check($flags | MultiExecState::INSIDEBLOCK));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSettingAndGettingWholeFlags()
+    {
+        $flags = MultiExecState::INITIALIZED | MultiExecState::CAS;
+        $state = new MultiExecState();
+        $state->set($flags);
+
+        $this->assertFalse($state->check(MultiExecState::INSIDEBLOCK));
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertTrue($state->check(MultiExecState::CAS));
+        $this->assertSame($flags, $state->get());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCanFlagSingleStates()
+    {
+        $flags = MultiExecState::INITIALIZED | MultiExecState::CAS;
+        $state = new MultiExecState();
+
+        $state->flag(MultiExecState::INITIALIZED);
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertFalse($state->check(MultiExecState::CAS));
+
+        $state->flag(MultiExecState::CAS);
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertTrue($state->check(MultiExecState::CAS));
+
+        $this->assertSame($flags, $state->get());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCanUnflagSingleStates()
+    {
+        $state = new MultiExecState();
+        $state->set(MultiExecState::INITIALIZED | MultiExecState::CAS);
+
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertTrue($state->check(MultiExecState::CAS));
+
+        $state->unflag(MultiExecState::CAS);
+        $this->assertTrue($state->check(MultiExecState::INITIALIZED));
+        $this->assertFalse($state->check(MultiExecState::CAS));
+
+        $state->unflag(MultiExecState::INITIALIZED);
+        $this->assertFalse($state->check(MultiExecState::INITIALIZED));
+        $this->assertFalse($state->check(MultiExecState::CAS));
+
+        $this->assertTrue($state->isReset());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsInitializedMethod()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isInitialized());
+
+        $state->set(MultiExecState::INITIALIZED);
+        $this->assertTrue($state->isInitialized());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsExecuting()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isExecuting());
+
+        $state->set(MultiExecState::INSIDEBLOCK);
+        $this->assertTrue($state->isExecuting());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsCAS()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isCAS());
+
+        $state->set(MultiExecState::CAS);
+        $this->assertTrue($state->isCAS());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsWatchAllowed()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isWatchAllowed());
+
+        $state->flag(MultiExecState::INITIALIZED);
+        $this->assertTrue($state->isWatchAllowed());
+
+        $state->flag(MultiExecState::CAS);
+        $this->assertFalse($state->isWatchAllowed());
+
+        $state->unflag(MultiExecState::CAS);
+        $this->assertTrue($state->isWatchAllowed());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsWatching()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isWatching());
+
+        $state->set(MultiExecState::WATCH);
+        $this->assertTrue($state->isWatching());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsDiscarded()
+    {
+        $state = new MultiExecState();
+
+        $this->assertFalse($state->isDiscarded());
+
+        $state->set(MultiExecState::DISCARDED);
+        $this->assertTrue($state->isDiscarded());
+    }
+}

+ 112 - 12
tests/Predis/Transaction/MultiExecTest.php

@@ -37,9 +37,9 @@ class MultiExecTest extends StandardTestCase
     /**
      * @group disconnected
      * @expectedException Predis\NotSupportedException
-     * @expectedExceptionMessage The current profile does not support WATCH and UNWATCH
+     * @expectedExceptionMessage WATCH is not supported by the current profile
      */
-    public function testThrowsExceptionOnUnsupportedWatchUnwatchInProfile()
+    public function testThrowsExceptionOnUnsupportedWatchInProfile()
     {
         $connection = $this->getMock('Predis\Connection\SingleConnectionInterface');
         $client = new Client($connection, array('profile' => '2.0'));
@@ -48,6 +48,20 @@ class MultiExecTest extends StandardTestCase
         $tx->watch('foo');
     }
 
+    /**
+     * @group disconnected
+     * @expectedException Predis\NotSupportedException
+     * @expectedExceptionMessage UNWATCH is not supported by the current profile
+     */
+    public function testThrowsExceptionOnUnsupportedUnwatchInProfile()
+    {
+        $connection = $this->getMock('Predis\Connection\SingleConnectionInterface');
+        $client = new Client($connection, array('profile' => '2.0'));
+        $tx = new MultiExec($client, array('options' => 'cas'));
+
+        $tx->unwatch('foo');
+    }
+
     /**
      * @group disconnected
      */
@@ -129,7 +143,7 @@ class MultiExecTest extends StandardTestCase
     /**
      * @group disconnected
      * @expectedException Predis\ClientException
-     * @expectedExceptionMessage Cannot invoke 'execute' or 'exec' inside an active client transaction block
+     * @expectedExceptionMessage Cannot invoke "execute" or "exec" inside an active transaction context
      */
     public function testThrowsExceptionOnExecInsideTransactionBlock()
     {
@@ -356,7 +370,7 @@ class MultiExecTest extends StandardTestCase
     /**
      * @group disconnected
      * @expectedException InvalidArgumentException
-     * @expectedExceptionMessage Automatic retries can be used only when a transaction block is provided
+     * @expectedExceptionMessage Automatic retries can be used only when a callable block is provided
      */
     public function testThrowsExceptionOnAutomaticRetriesWithFluentInterface()
     {
@@ -469,6 +483,92 @@ class MultiExecTest extends StandardTestCase
         $this->assertSame(array('MULTI', 'SET', 'ECHO', 'DISCARD'), self::commandsToIDs($commands));
     }
 
+    /**
+     * @group disconnected
+     */
+    public function testExceptionsOptionTakesPrecedenceOverClientOptionsWhenFalse()
+    {
+        $expected = array('before', new Response\Error('ERR simulated error'), 'after');
+
+        $connection = $this->getMockedConnection(function (CommandInterface $command) use ($expected) {
+            switch ($command->getId()) {
+                case 'MULTI':
+                    return true;
+
+                case 'EXEC':
+                    return $expected;
+
+                default:
+                    return new Response\StatusQueued();
+            }
+        });
+
+        $client = new Client($connection, array('exceptions' => true));
+        $tx = new MultiExec($client, array('exceptions' => false));
+
+        $result = $tx->multi()
+                     ->echo('before')
+                     ->echo('ERROR PLEASE!')
+                     ->echo('after')
+                     ->exec();
+
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\Response\ServerException
+     * @expectedExceptionMessage ERR simulated error
+     */
+    public function testExceptionsOptionTakesPrecedenceOverClientOptionsWhenTrue()
+    {
+        $expected = array('before', new Response\Error('ERR simulated error'), 'after');
+
+        $connection = $this->getMockedConnection(function (CommandInterface $command) use ($expected) {
+            switch ($command->getId()) {
+                case 'MULTI':
+                    return true;
+
+                case 'EXEC':
+                    return $expected;
+
+                default:
+                    return new Response\StatusQueued();
+            }
+        });
+
+        $client = new Client($connection, array('exceptions' => false));
+        $tx = new MultiExec($client, array('exceptions' => true));
+
+        $tx->multi()->echo('before')->echo('ERROR PLEASE!')->echo('after')->exec();
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\Response\ServerException
+     * @expectedExceptionMessage ERR simulated failure on EXEC
+     */
+    public function testExceptionsOptionDoesNotAffectTransactionControlCommands()
+    {
+        $connection = $this->getMockedConnection(function (CommandInterface $command) {
+            switch ($command->getId()) {
+                case 'MULTI':
+                    return true;
+
+                case 'EXEC':
+                    return new Response\Error('ERR simulated failure on EXEC');
+
+                default:
+                    return new Response\StatusQueued();
+            }
+        });
+
+        $client = new Client($connection, array('exceptions' => false));
+        $tx = new MultiExec($client);
+
+        $tx->multi()->echo('test')->exec();
+    }
+
     // ******************************************************************** //
     // ---- INTEGRATION TESTS --------------------------------------------- //
     // ******************************************************************** //
@@ -629,7 +729,7 @@ class MultiExecTest extends StandardTestCase
 
     /**
      * Returns a mocked instance of Predis\Connection\SingleConnectionInterface
-     * usingthe specified callback to return values from executeCommand().
+     * using the specified callback to return values from executeCommand().
      *
      * @param \Closure $executeCallback
      * @return \Predis\Connection\SingleConnectionInterface
@@ -652,11 +752,11 @@ class MultiExecTest extends StandardTestCase
      * @param \Closure $executeCallback
      * @return MultiExec
      */
-    protected function getMockedTransaction($executeCallback, $options = array())
+    protected function getMockedTransaction($executeCallback, $txOpts = null, $clientOpts = null)
     {
         $connection = $this->getMockedConnection($executeCallback);
-        $client = new Client($connection);
-        $transaction = new MultiExec($client, $options);
+        $client = new Client($connection, $clientOpts ?: array());
+        $transaction = new MultiExec($client, $txOpts ?: array());
 
         return $transaction;
     }
@@ -684,21 +784,21 @@ class MultiExecTest extends StandardTestCase
             switch ($cmd) {
                 case 'WATCH':
                     if ($multi) {
-                        throw new Response\ServerException("ERR $cmd inside MULTI is not allowed");
+                        return new Response\Error("ERR $cmd inside MULTI is not allowed");
                     }
 
                     return $watch = true;
 
                 case 'MULTI':
                     if ($multi) {
-                        throw new Response\ServerException("ERR MULTI calls can not be nested");
+                        return new Response\Error("ERR MULTI calls can not be nested");
                     }
 
                     return $multi = true;
 
                 case 'EXEC':
                     if (!$multi) {
-                        throw new Response\ServerException("ERR $cmd without MULTI");
+                        return new Response\Error("ERR $cmd without MULTI");
                     }
 
                     $watch = $multi = false;
@@ -713,7 +813,7 @@ class MultiExecTest extends StandardTestCase
 
                 case 'DISCARD':
                     if (!$multi) {
-                        throw new Response\ServerException("ERR $cmd without MULTI");
+                        return new Response\Error("ERR $cmd without MULTI");
                     }
 
                     $watch = $multi = false;