diff --git a/src/Jackalope/Session.php b/src/Jackalope/Session.php index 2754a5f9..cc2aeb0f 100644 --- a/src/Jackalope/Session.php +++ b/src/Jackalope/Session.php @@ -38,6 +38,7 @@ class Session implements \PHPCR\SessionInterface protected $repository; protected $workspace; protected $objectManager; + protected $utx = null; protected $credentials; protected $logout = false; /** @@ -63,12 +64,18 @@ public function __construct($factory, Repository $repository, $workspaceName, \P $this->factory = $factory; $this->repository = $repository; $this->objectManager = $this->factory->get('ObjectManager', array($transport, $this)); + $this->utx = $this->factory->get('Transaction\\UserTransaction', array($transport, $this)); $this->workspace = $this->factory->get('Workspace', array($this, $this->objectManager, $workspaceName)); $this->credentials = $credentials; $this->namespaceRegistry = $this->workspace->getNamespaceRegistry(); self::registerSession($this); } + public function getTransactionManager() + { + return $this->utx; + } + /** * Returns the Repository object through which this session was acquired. * @@ -458,7 +465,14 @@ public function removeItem($absPath) */ public function save() { - $this->objectManager->save(); + if (! $this->utx->inTransaction()) + { + $this->utx->begin(); + $this->objectManager->save(); + $this->utx->commit(); + } else { + $this->objectManager->save(); + } } /** diff --git a/src/Jackalope/Transaction/UserTransaction.php b/src/Jackalope/Transaction/UserTransaction.php new file mode 100644 index 00000000..b4cd5f9c --- /dev/null +++ b/src/Jackalope/Transaction/UserTransaction.php @@ -0,0 +1,167 @@ +factory = $factory; + $this->transport = $transport; + $this->session = $session; + } + + + /** + * Begin new transaction associated with current session. + * + * @return void + * + * @throws \PHPCR\UnsupportedRepositoryOperationException Thrown if a transaction + * is already started and the transaction implementation or backend does not + * support nested transactions. + * + * @throws \PHPCR\RepositoryException Thrown if the transaction implementation + * encounters an unexpected error condition. + */ + public function begin() + { + if ($this->inTransaction) { + throw new \PHPCR\UnsupportedRepositoryOperationException("Nested transactions are not supported."); + } + + $this->transport->beginTransaction(); + $this->inTransaction = true; + } + + /** + * + * Complete the transaction associated with the current session. + * TODO: Make shure RollbackException and AccessDeniedException are thrown by the transport + * if corresponding problems occure + * + * @return void + * + * @throws \PHPCR\Transaction\RollbackException Thrown to indicate that the + * transaction has been rolled back rather than committed. + * @throws \PHPCR\AccessDeniedException Thrown to indicate that the + * session is not allowed to commit the transaction. + * @throws LogicException Thrown if the current + * session is not associated with a transaction. + * @throws \PHPCR\RepositoryException Thrown if the transaction implementation + * encounters an unexpected error condition. + */ + public function commit() + { + if (! $this->inTransaction) { + throw new LogicException("No transaction to commit."); + } + + $this->transport->commitTransaction(); + $this->inTransaction = false; + } + + /** + * + * Obtain the status if the current session is inside of a transaction or not. + * + * @return boolean + * + * @throws \PHPCR\RepositoryException Thrown if the transaction implementation + * encounters an unexpected error condition. + */ + public function inTransaction() + { + //TODO Is there a way to ask for the transaction status via webdav? + return $this->inTransaction; + } + + /** + * + * Roll back the transaction associated with the current session. + * TODO: Make shure AccessDeniedException is thrown by the transport + * if corresponding problems occure + * + * @return void + * + * @throws \PHPCR\AccessDeniedException Thrown to indicate that the + * application is not allowed to roll back the transaction. + * @throws LogicException Thrown if the current + * session is not associated with a transaction. + * @throws \PHPCR\RepositoryException Thrown if the transaction implementation + * encounters an unexpected error condition. + */ + public function rollback() + { + if (! $this->inTransaction) { + throw new LogicException("No transaction to rollback."); + } + + $this->transport->rollbackTransaction(); + $this->inTransaction = false; + $this->session->clear(); + } + + /** + * + * Modify the timeout value that is associated with transactions started by + * the current application with the begin method. If an application has not + * called this method, the transaction service uses some default value for the + * transaction timeout. + * + * @param int $seconds The value of the timeout in seconds. If the value is zero, + * the transaction service restores the default value. If the value is + * negative a RepositoryException is thrown. + * + * @return void + * + * @throws \PHPCR\RepositoryException Thrown if the transaction implementation + * encounters an unexpected error condition. + */ + public function setTransactionTimeout($seconds = 0) + { + if ($seconds < 0) { + throw new \PHPCR\RepositoryException("Value must be possitiv or 0. ". $seconds ." given."); + } + $this->transport->setTransactionTimeout($seconds); + } +} diff --git a/src/Jackalope/Transport/Davex/Client.php b/src/Jackalope/Transport/Davex/Client.php index 28fc16e2..20f3df9e 100755 --- a/src/Jackalope/Transport/Davex/Client.php +++ b/src/Jackalope/Transport/Davex/Client.php @@ -147,6 +147,14 @@ class Client implements TransportInterface */ protected $checkLoginOnServer = true; + /** + * The transaction token received by a LOCKing request + * + * Is FALSE while no transaction running. + * @var string|FALSE + */ + protected $transactionToken = false; + /** * Create a transport pointing to a server url. * @@ -196,7 +204,7 @@ public function sendExpect($send = true) /** * Opens a cURL session if not yet one open. * - * @return null|false False in case there is already an open connection, else null; + * @return Jackalope\Transport\Davex\Request The Request */ protected function getRequest($method, $uri) { @@ -406,6 +414,7 @@ public function getNode($path) $path .= '.0.json'; $request = $this->getRequest(Request::GET, $path); + $request->setTransactionId($this->transactionToken); try { return $request->executeJson(); } catch (\PHPCR\PathNotFoundException $e) { @@ -436,7 +445,7 @@ public function getNodes($paths) } } $body = array(); - + $url = $this->encodePathForDavex($url).".0.json"; foreach ($paths as $path) { $body[] = http_build_query(array(":get"=>$path)); @@ -445,6 +454,7 @@ public function getNodes($paths) $request = $this->getRequest(Request::POST, $url); $request->setBody($body); $request->setContentType('application/x-www-form-urlencoded'); + $request->setTransactionId($this->transactionToken); try { $data = $request->executeJson(); return $data->nodes; @@ -452,8 +462,8 @@ public function getNodes($paths) throw new \PHPCR\ItemNotFoundException($e->getMessage(), $e->getCode(), $e); } catch (\PHPCR\RepositoryException $e) { if ($e->getMessage() == 'HTTP 403: Prefix must not be empty (org.apache.jackrabbit.spi.commons.conversion.IllegalNameException)') { - throw new \PHPCR\UnsupportedRepositoryOperationException("Jackalope currently needs a patched jackrabbit for Session->getNodes() to work. Until our patches make it into the official distribution, see https://github.com/jackalope/jackrabbit/blob/2.2-jackalope/README.jackalope.patches.md for details and downloads."); - } + throw new \PHPCR\UnsupportedRepositoryOperationException("Jackalope currently needs a patched jackrabbit for Session->getNodes() to work. Until our patches make it into the official distribution, see https://github.com/jackalope/jackrabbit/blob/2.2-jackalope/README.jackalope.patches.md for details and downloads."); + } throw $e; } } @@ -487,6 +497,7 @@ public function getBinaryStream($path) { $path = $this->encodePathForDavex($path); $request = $this->getRequest(Request::GET, $path); + $request->setTransactionId($this->transactionToken); $curl = $request->execute(true); switch($curl->getHeader('Content-Type')) { case 'text/xml; charset=utf-8': @@ -575,6 +586,7 @@ protected function getNodeReferences($path, $name = null, $weak_reference = fals $path = $this->encodePathForDavex($path); $identifier = $weak_reference ? 'weakreferences' : 'references'; $request = $this->getRequest(Request::PROPFIND, $path); + $request->setTransactionId($this->transactionToken); $request->setBody($this->buildPropfindRequest(array('dcr:'.$identifier))); $request->setDepth(0); $dom = $request->executeDom(); @@ -607,6 +619,7 @@ public function checkinItem($path) $path = $this->encodePathForDavex($path); try { $request = $this->getRequest(Request::CHECKIN, $path); + $request->setTransactionId($this->transactionToken); $curl = $request->execute(true); if ($curl->getHeader("Location")) { return $this->stripServerRootFromUri(urldecode($curl->getHeader("Location"))); @@ -636,6 +649,7 @@ public function checkoutItem($path) $path = $this->encodePathForDavex($path); try { $request = $this->getRequest(Request::CHECKOUT, $path); + $request->setTransactionId($this->transactionToken); $request->execute(); } catch (\Jackalope\Transport\Davex\HTTPErrorException $e) { if ($e->getCode() == 405) { @@ -661,6 +675,7 @@ public function restoreItem($removeExisting, $versionPath, $path) $request = $this->getRequest(Request::UPDATE, $path); $request->setBody($body); + $request->setTransactionId($this->transactionToken); $request->execute(); // errors are checked in request } @@ -668,6 +683,7 @@ public function getVersionHistory($path) { $path = $this->encodePathForDavex($path); $request = $this->getRequest(Request::GET, $path."/jcr:versionHistory"); + $request->setTransactionId($this->transactionToken); $resp = $request->execute(); return $resp; } @@ -695,6 +711,7 @@ public function query(\PHPCR\Query\QueryInterface $query) $path = $this->addWorkspacePathToUri('/'); $request = $this->getRequest(Request::SEARCH, $path); + $request->setTransactionId($this->transactionToken); $request->setBody($body); $rawData = $request->execute(); @@ -737,6 +754,7 @@ public function deleteNode($path) $path = $this->encodePathForDavex($path); $request = $this->getRequest(Request::DELETE, $path); + $request->setTransactionId($this->transactionToken); $request->execute(); return true; } @@ -777,6 +795,7 @@ public function copyNode($srcAbsPath, $dstAbsPath, $srcWorkspace = null) $request = $this->getRequest(Request::COPY, $srcAbsPath); $request->setDepth(Request::INFINITY); $request->addHeader('Destination: '.$this->addWorkspacePathToUri($dstAbsPath)); + $request->setTransactionId($this->transactionToken); $request->execute(); } @@ -797,6 +816,7 @@ public function moveNode($srcAbsPath, $dstAbsPath) $request = $this->getRequest(Request::MOVE, $srcAbsPath); $request->setDepth(Request::INFINITY); $request->addHeader('Destination: '.$this->addWorkspacePathToUri($dstAbsPath)); + $request->setTransactionId($this->transactionToken); $request->execute(); } @@ -827,12 +847,14 @@ public function storeNode(\PHPCR\NodeInterface $node) $request = $this->getRequest(Request::MKCOL, $path); $request->setBody($body); + $request->setTransactionId($this->transactionToken); $request->execute(); // store single-valued multivalue properties separately foreach ($buffer as $path => $body) { $request = $this->getRequest(Request::PUT, $path); $request->setBody($body); + $request->setTransactionId($this->transactionToken); $request->execute(); } @@ -932,6 +954,7 @@ public function storeProperty(\PHPCR\PropertyInterface $property) $request->setContentType('jcr-value/'.strtolower($type)); } $request->setBody($body); + $request->setTransactionId($this->transactionToken); $request->execute(); return true; @@ -997,6 +1020,7 @@ public function getNodePathForIdentifier($uuid) { $request = $this->getRequest(Request::REPORT, $this->workspaceUri); $request->setBody($this->buildLocateRequest($uuid)); + $request->setTransactionId($this->transactionToken); $dom = $request->executeDom(); /* answer looks like @@ -1031,6 +1055,7 @@ public function getNamespaces() { $request = $this->getRequest(Request::REPORT, $this->workspaceUri); $request->setBody($this->buildReportRequest('dcr:registerednamespaces')); + $request->setTransactionId($this->transactionToken); $dom = $request->executeDom(); if ($dom->firstChild->localName != 'registerednamespaces-report' @@ -1081,6 +1106,7 @@ public function registerNamespace($prefix, $uri) $request = $this->getRequest(Request::PROPPATCH, $this->workspaceUri); $namespaces[$prefix] = $uri; $request->setBody($this->buildRegisterNamespaceRequest($namespaces)); + $request->setTransactionId($this->transactionToken); $request->execute(); return true; } @@ -1104,6 +1130,7 @@ public function unregisterNamespace($prefix) $namespaces = $this->getNamespaces(); unset($namespaces[$prefix]); $request->setBody($this->buildRegisterNamespaceRequest($namespaces)); + $request->setTransactionId($this->transactionToken); $request->execute(); return true; */ @@ -1121,6 +1148,7 @@ public function getNodeTypes($nodeTypes = array()) { $request = $this->getRequest(Request::REPORT, $this->workspaceUriRoot); $request->setBody($this->buildNodeTypesRequest($nodeTypes)); + $request->setTransactionId($this->transactionToken); $dom = $request->executeDom(); if ($dom->firstChild->localName != 'nodeTypes') { @@ -1134,6 +1162,81 @@ public function getNodeTypes($nodeTypes = array()) return $this->typeXmlConverter->getNodeTypesFromXml($dom); } + + + /** + * Initiates a «local transaction» on the root node + * + * @return string The received transaction token + * @throws \PHPCR\RepositoryException If no transaction token received + */ + public function beginTransaction() + { + $request = $this->getRequest(Request::LOCK, $this->workspaceUriRoot); + $request->setDepth('infinity'); + $request->setTransactionId($this->transactionToken); + $request->setBody(''. + ''. + ' '. + ' '. + ''); + + $dom = $request->executeDom(); + $hrefs = $dom->getElementsByTagNameNS(self::NS_DAV, 'href'); + + if (!$hrefs->length) { + throw new \PHPCR\RepositoryException('No transaction token received'); + } + $this->transactionToken = $hrefs->item(0)->textContent; + return $this->transactionToken; + } + + /** + * Ends the transaction started with {@link beginTransaction()} + * + * @param string $tag Either 'commit' or 'rollback' + */ + protected function endTransaction($tag) { + + if ($tag != 'commit' && $tag != 'rollback') { + throw new \InvalidArgumentException('Expected \'commit\' or \'rollback\' as argument'); + } + + $request = $this->getRequest(Request::UNLOCK, $this->workspaceUriRoot); + $request->setLockToken($this->transactionToken); + $request->setBody(''. + ''. + ' '. + ''); + + $request->execute(); + $this->transactionToken = false; + } + + /** + * Commits a transaction started with {@link beginTransaction()} + */ + public function commitTransaction() { + $this->endTransaction('commit'); + } + + /** + * Rollbacks a transaction started with {@link beginTransaction()} + */ + public function rollbackTransaction() { + $this->endTransaction('rollback'); + } + + /** + * Sets the default transaction timeout + * + * @param int $seconds The value of the timeout in seconds + */ + public function setTransactionTimeout($seconds) { + throw new NotImplementedException(); + } + + /** * Register namespaces and new node types or update node types based on a * jackrabbit cnd string @@ -1149,6 +1252,7 @@ public function getNodeTypes($nodeTypes = array()) public function registerNodeTypesCnd($cnd, $allowUpdate) { $request = $this->getRequest(Request::PROPPATCH, $this->workspaceUri); + $request->setTransactionId($this->transactionToken); $request->setBody($this->buildRegisterNodeTypeRequest($cnd, $allowUpdate)); $request->execute(); return true; @@ -1190,6 +1294,7 @@ public function getPermissions($path) $request = $this->getRequest(Request::REPORT, $this->workspaceUri); $request->setBody($body); + $request->setTransactionId($this->transactionToken); $dom = $request->executeDom(); foreach($dom->getElementsByTagNameNS(self::NS_DAV, 'current-user-privilege-set') as $node) { diff --git a/src/Jackalope/Transport/Davex/Request.php b/src/Jackalope/Transport/Davex/Request.php index 18168d23..20bf8025 100644 --- a/src/Jackalope/Transport/Davex/Request.php +++ b/src/Jackalope/Transport/Davex/Request.php @@ -89,6 +89,19 @@ class Request */ const PROPPATCH = 'PROPPATCH'; + /** + * Identifier of the 'LOCK' http request method + * @var string + */ + const LOCK = 'LOCK'; + + + /** + * Identifier of the 'UNLOCK' http request method + * @var string + */ + const UNLOCK = 'UNLOCK'; + /** * Identifier of the 'COPY' http request method. * @var string @@ -166,6 +179,19 @@ class Request /** @var array[]string A list of additional HTTP headers to be sent */ protected $additionalHeaders = array(); + /** + * The lock token active for this request otherwise FALSE for no locking + * @var string|FALSE + */ + protected $lockToken = false; + + /** + * The transaction id active for this request otherwise FALSE for not + * performing a transaction + * @var string|FALSE + */ + protected $transactionId = false; + /** * Initiaties the NodeTypes request object. * @@ -221,6 +247,16 @@ public function addHeader($header) $this->additionalHeaders[] = $header; } + public function setLockToken($lockToken) + { + $this->lockToken = (string) $lockToken; + } + + public function setTransactionId($transactionId) + { + $this->transactionId = (string) $transactionId; + } + protected function prepareCurl($curl, $getCurlObject) { if ($this->credentials instanceof \PHPCR\SimpleCredentials) { @@ -236,6 +272,14 @@ protected function prepareCurl($curl, $getCurlObject) ); $headers = array_merge($headers, $this->additionalHeaders); + if ($this->lockToken) { + $headers[] = 'Lock-Token: <'.$this->lockToken.'>'; + } + + if ($this->transactionId) { + $headers[] = 'TransactionId: <'.$this->transactionId.'>'; + } + $curl->setopt(CURLOPT_RETURNTRANSFER, true); $curl->setopt(CURLOPT_CUSTOMREQUEST, $this->method); @@ -338,6 +382,14 @@ protected function singleRequest($getCurlObject) ); $headers = array_merge($headers, $this->additionalHeaders); + if ($this->lockToken) { + $headers[] = 'Lock-Token: <'.$this->lockToken.'>'; + } + + if ($this->transactionId) { + $headers[] = 'TransactionId: <'.$this->transactionId.'>'; + } + $this->curl->setopt(CURLOPT_RETURNTRANSFER, true); $this->curl->setopt(CURLOPT_CUSTOMREQUEST, $this->method); $this->curl->setopt(CURLOPT_URL, reset($this->uri)); @@ -400,6 +452,9 @@ protected function handleError($curl, $response, $httpCode) throw new \PHPCR\ItemNotFoundException($exceptionMsg); case 'javax.jcr.nodetype.ConstraintViolationException': throw new \PHPCR\NodeType\ConstraintViolationException($exceptionMsg); + //TODO: Two more errors needed for Transactions. How does the corresponding Jackrabbit response look like? + // javax.transaction.RollbackException => \PHPCR\Transaction\RollbackException + // java.lang.SecurityException => \PHPCR\AccessDeniedException //TODO: map more errors here? default: