├── .gitignore ├── lib └── Doctrine │ └── CouchDB │ ├── Version.php │ ├── HTTP │ ├── ErrorResponse.php │ ├── Response.php │ ├── Client.php │ ├── HTTPException.php │ ├── LoggingClient.php │ ├── AbstractHTTPClient.php │ ├── StreamClient.php │ ├── SocketClient.php │ └── MultipartParserAndSender.php │ ├── JsonDecodeException.php │ ├── Tools │ ├── Console │ │ ├── Command │ │ │ ├── ViewCleanupCommand.php │ │ │ ├── CompactDatabaseCommand.php │ │ │ ├── CompactViewCommand.php │ │ │ ├── MigrationCommand.php │ │ │ ├── ReplicationCancelCommand.php │ │ │ └── ReplicationStartCommand.php │ │ └── Helper │ │ │ └── CouchDBHelper.php │ └── Migrations │ │ └── AbstractMigration.php │ ├── View │ ├── DesignDocument.php │ ├── LuceneResult.php │ ├── Result.php │ ├── FolderDesignDocument.php │ ├── LuceneQuery.php │ ├── AbstractQuery.php │ └── Query.php │ ├── MangoClient.php │ ├── CouchDBException.php │ ├── Utils │ └── BulkUpdater.php │ ├── Mango │ └── MangoQuery.php │ ├── Attachment.php │ └── CouchDBClient.php ├── tests ├── Doctrine │ └── Tests │ │ ├── Models │ │ └── CMS │ │ │ └── _files │ │ │ ├── filters │ │ │ └── my_filter.js │ │ │ ├── views │ │ │ └── username │ │ │ │ └── map.js │ │ │ └── rewrites.json │ │ └── CouchDB │ │ ├── TestUtil.php │ │ ├── CouchDBExceptionTest.php │ │ ├── CouchDBFunctionalTestCase.php │ │ ├── CouchDBClientTest.php │ │ └── Functional │ │ ├── BulkUpdaterTest.php │ │ ├── MangoQueryTest.php │ │ ├── MangoTest.php │ │ ├── HTTP │ │ └── MultipartParserAndSenderTest.php │ │ └── CouchDBClientTest.php └── TestInit.php ├── .travis.yml ├── .doctrine-project.json ├── phpunit.xml.dist ├── composer.json ├── LICENSE ├── README.md └── docs └── en └── index.rst /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .idea 3 | composer.lock -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Version.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class ErrorResponse extends Response 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /.doctrine-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "active": false, 3 | "name": "CouchDB Client", 4 | "shortName": "CouchDB Client", 5 | "slug": "couchdb-client", 6 | "docsSlug": "doctrine-couchdb-client", 7 | "versions": [ 8 | { 9 | "name": "master", 10 | "branchName": "master", 11 | "slug": "latest", 12 | "aliases": [ 13 | "current", 14 | "stable" 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/TestUtil.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:maintenance:view-cleanup') 14 | ->setDescription('Cleanup deleted views'); 15 | } 16 | 17 | protected function execute(InputInterface $input, OutputInterface $output) 18 | { 19 | $couchClient = $this->getHelper('couchdb')->getCouchDBClient(); 20 | /* @var $couchClient \Doctrine\CouchDB\CouchDBClient */ 21 | 22 | $data = $couchClient->viewCleanup(); 23 | $output->writeln('View cleanup started.'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ./tests/Doctrine/Tests/CouchDB 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doctrine/couchdb", 3 | "type": "library", 4 | "description": "CouchDB Client", 5 | "keywords": ["persistence", "couchdb"], 6 | "homepage": "http://www.doctrine-project.org", 7 | "license": "MIT", 8 | "authors": [ 9 | {"name": "Benjamin Eberlei", "email": "kontakt@beberlei.de"}, 10 | {"name": "Lukas Kahwe Smith", "email": "smith@pooteeweet.org"} 11 | ], 12 | "require": { 13 | "php": ">=5.4" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "~4.0" 17 | }, 18 | "autoload": { 19 | "psr-0": { 20 | "Doctrine\\CouchDB": "lib/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-0": { 25 | "Doctrine\\Tests": "tests/" 26 | } 27 | }, 28 | "extra": { 29 | "branch-alias": { 30 | "dev-master": "1.0-dev" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Console/Command/CompactDatabaseCommand.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:maintenance:compact-database') 14 | ->setDescription('Compact the database'); 15 | } 16 | 17 | protected function execute(InputInterface $input, OutputInterface $output) 18 | { 19 | $couchClient = $this->getHelper('couchdb')->getCouchDBClient(); 20 | /* @var $couchClient \Doctrine\CouchDB\CouchDBClient */ 21 | 22 | $data = $couchClient->compactDatabase(); 23 | $output->writeln('Database compact started.'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/DesignDocument.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | interface DesignDocument 16 | { 17 | /** 18 | * Get design doc code. 19 | * 20 | * Return the view (or general design doc) code, which should be 21 | * committed to the database, which should be structured like: 22 | * 23 | * 24 | * array( 25 | * "views" => array( 26 | * "name" => array( 27 | * "map" => "code", 28 | * ["reduce" => "code"], 29 | * ), 30 | * ... 31 | * ) 32 | * ) 33 | * 34 | */ 35 | public function getData(); 36 | } 37 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/LuceneResult.php: -------------------------------------------------------------------------------- 1 | result['etag']; 10 | } 11 | 12 | public function getFetchDuration() 13 | { 14 | return $this->result['fetch_duration']; 15 | } 16 | 17 | public function getLimit() 18 | { 19 | return $this->result['limit']; 20 | } 21 | 22 | public function getExecutedQuery() 23 | { 24 | return $this->result['q']; 25 | } 26 | 27 | public function getRows() 28 | { 29 | return $this->result['rows']; 30 | } 31 | 32 | public function getSearchDuration() 33 | { 34 | return $this->result['search_duration']; 35 | } 36 | 37 | public function getSkip() 38 | { 39 | return $this->result['skip']; 40 | } 41 | 42 | public function getTotalRows() 43 | { 44 | return $this->result['total_rows']; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2015 Doctrine Project 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Console/Command/CompactViewCommand.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:maintenance:compact-view') 15 | ->setDescription('Compat the given view') 16 | ->setDefinition([ 17 | new InputArgument('designdoc', InputArgument::REQUIRED, 'Design document name', null), 18 | ]); 19 | } 20 | 21 | protected function execute(InputInterface $input, OutputInterface $output) 22 | { 23 | $couchClient = $this->getHelper('couchdb')->getCouchDBClient(); 24 | /* @var $couchClient \Doctrine\CouchDB\CouchDBClient */ 25 | 26 | $data = $couchClient->compactView($input->getArgument('designdoc')); 27 | $output->writeln('View compact started.'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/Response.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class Response 16 | { 17 | /** 18 | * HTTP response status. 19 | * 20 | * @var int 21 | */ 22 | public $status; 23 | 24 | /** 25 | * HTTP response headers. 26 | * 27 | * @var array 28 | */ 29 | public $headers; 30 | 31 | /** 32 | * Decoded JSON response body. 33 | * 34 | * @var array 35 | */ 36 | public $body; 37 | 38 | /** 39 | * Construct response. 40 | * 41 | * @param $status 42 | * @param array $headers 43 | * @param string $body 44 | * @param bool $raw 45 | * 46 | * @return void 47 | */ 48 | public function __construct($status, array $headers, $body, $raw = false) 49 | { 50 | $this->status = (int) $status; 51 | $this->headers = $headers; 52 | $this->body = $raw ? $body : json_decode($body, true); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Console/Command/MigrationCommand.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:migrate') 15 | ->setDescription('Execute a migration in CouchDB.') 16 | ->setDefinition([ 17 | new InputArgument('class', InputArgument::REQUIRED, 'Migration class name', null), 18 | ]); 19 | } 20 | 21 | protected function execute(InputInterface $input, OutputInterface $output) 22 | { 23 | $className = $input->getArgument('class'); 24 | if (!class_exists($className) || !in_array('Doctrine\CouchDB\Tools\Migrations\AbstractMigration', class_parents($className))) { 25 | throw new \InvalidArgumentException("class passed to command has to extend 'Doctrine\CouchDB\Tools\Migrations\AbstractMigration'"); 26 | } 27 | $migration = new $className($this->getHelper('couchdb')->getCouchDBClient()); 28 | $migration->execute(); 29 | 30 | $output->writeln('Migration was successfully executed!'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/Result.php: -------------------------------------------------------------------------------- 1 | result = $result; 12 | } 13 | 14 | public function getTotalRows() 15 | { 16 | return $this->result['total_rows']; 17 | } 18 | 19 | public function getIterator() 20 | { 21 | return new \ArrayIterator($this->result['rows']); 22 | } 23 | 24 | public function count() 25 | { 26 | return count($this->result['rows']); 27 | } 28 | 29 | public function offsetExists($offset) 30 | { 31 | return isset($this->result['rows'][$offset]); 32 | } 33 | 34 | public function offsetGet($offset) 35 | { 36 | return $this->result['rows'][$offset]; 37 | } 38 | 39 | public function offsetSet($offset, $value) 40 | { 41 | throw new \BadMethodCallException('Result is immutable and cannot be changed.'); 42 | } 43 | 44 | public function offsetUnset($offset) 45 | { 46 | throw new \BadMethodCallException('Result is immutable and cannot be changed.'); 47 | } 48 | 49 | public function toArray() 50 | { 51 | return $this->result['rows']; 52 | } 53 | 54 | public function getOffset() 55 | { 56 | return $this->result['offset']; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/CouchDBExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedMessage, $exception->getMessage()); 19 | } 20 | 21 | public function staticFactoryDataProvider() 22 | { 23 | return [ 24 | ['unknownDocumentNamespace', 'Unknown Document namespace alias \'a\'.'], 25 | ['unregisteredDesignDocument', 'No design document with name \'a\' was registered with the DocumentManager.'], 26 | ['invalidAttachment', 'Trying to save invalid attachment with filename c in document a with id b'], 27 | ['detachedDocumentFound', 'Found a detached or new document at property a::c of document with ID b, but the assocation is not marked as cascade persist.'], 28 | ['persistRemovedDocument', 'Trying to persist document that is scheduled for removal.'], 29 | ['luceneNotConfigured', 'CouchDB Lucene is not configured. You have to configure the handler name to enable support for Lucene Queries.'], 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/Client.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class CouchDBHelper extends Helper 20 | { 21 | protected $couchDBClient; 22 | 23 | /** 24 | * @var DocumentManager 25 | */ 26 | protected $dm; 27 | 28 | /** 29 | * Constructor. 30 | * 31 | * @param CouchDBClient $couchDBClient 32 | * @param DocumentManager $dm 33 | */ 34 | public function __construct(CouchDBClient $couchDBClient = null, DocumentManager $dm = null) 35 | { 36 | if (!$couchDBClient && $dm) { 37 | $couchDBClient = $dm->getCouchDBClient(); 38 | } 39 | 40 | $this->couchDBClient = $couchDBClient; 41 | $this->dm = $dm; 42 | } 43 | 44 | /** 45 | * Retrieves Doctrine ODM CouchDB Manager. 46 | * 47 | * @return \Doctrine\ODM\CouchDB\DocumentManager 48 | */ 49 | public function getDocumentManager() 50 | { 51 | return $this->dm; 52 | } 53 | 54 | /** 55 | * @return CouchDBClient 56 | */ 57 | public function getCouchDBClient() 58 | { 59 | return $this->couchDBClient; 60 | } 61 | 62 | /** 63 | * @see Helper 64 | */ 65 | public function getName() 66 | { 67 | return 'couchdb'; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/MangoClient.php: -------------------------------------------------------------------------------- 1 | databaseName.'/_index'; 18 | 19 | $params = ['index'=>['fields'=>$fields]]; 20 | 21 | if ($ddoc) { 22 | $params['ddoc'] = $ddoc; 23 | } 24 | if ($name) { 25 | $params['name'] = $name; 26 | } 27 | 28 | return $this->httpClient->request('POST', $documentPath, json_encode($params)); 29 | } 30 | 31 | /** 32 | * Delete a mango query index and return the HTTP response. 33 | * 34 | * @param string $ddoc - design document name 35 | * @param string $name - view name 36 | */ 37 | public function deleteMangoIndex($ddoc, $name) 38 | { 39 | $documentPath = '/'.$this->databaseName.'/_index/_design/'.$ddoc.'/json/'.$name; 40 | $response = $this->httpClient->request('DELETE', $documentPath); 41 | 42 | return (isset($response->body['ok'])) ? true : false; 43 | } 44 | 45 | /** 46 | * Find documents using Mango Query. 47 | * 48 | * @param MangoQuery $query 49 | * 50 | * @return HTTP\Response 51 | */ 52 | public function find(MangoQuery $query) 53 | { 54 | $documentPath = '/'.$this->databaseName.'/_find'; 55 | return $this->httpClient->request('POST', $documentPath, json_encode($query)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Migrations/AbstractMigration.php: -------------------------------------------------------------------------------- 1 | client = $client; 16 | } 17 | 18 | /** 19 | * Execute migration by iterating over all documents in batches of 100. 20 | * 21 | * @throws \RuntimeException 22 | * 23 | * @return void 24 | */ 25 | public function execute() 26 | { 27 | $response = $this->client->allDocs(100); 28 | $lastKey = null; 29 | 30 | do { 31 | if ($response->status !== 200) { 32 | throw new \RuntimeException('Error while migrating at offset '.$offset); 33 | } 34 | 35 | $bulkUpdater = $this->client->createBulkUpdater(); 36 | foreach ($response->body['rows'] as $row) { 37 | $doc = $this->migrate($row['doc']); 38 | if ($doc) { 39 | $bulkUpdater->updateDocument($doc); 40 | } 41 | $lastKey = $row['key']; 42 | } 43 | 44 | $bulkUpdater->execute(); 45 | $response = $this->client->allDocs(100, $lastKey); 46 | } while (count($response->body['rows']) > 1); 47 | } 48 | 49 | /** 50 | * Return an array of to migrate to document data or null if this document should not be migrated. 51 | * 52 | * @param array $docData 53 | * 54 | * @return array|bool|null $docData 55 | */ 56 | abstract protected function migrate(array $docData); 57 | } 58 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/CouchDBException.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class CouchDBException extends \Exception 16 | { 17 | public static function unknownDocumentNamespace($documentNamespaceAlias) 18 | { 19 | return new self("Unknown Document namespace alias '$documentNamespaceAlias'."); 20 | } 21 | 22 | public static function unregisteredDesignDocument($designDocumentName) 23 | { 24 | return new self("No design document with name '".$designDocumentName."' was registered with the DocumentManager."); 25 | } 26 | 27 | public static function invalidAttachment($className, $id, $filename) 28 | { 29 | return new self('Trying to save invalid attachment with filename '.$filename.' in document '.$className.' with id '.$id); 30 | } 31 | 32 | public static function detachedDocumentFound($className, $id, $assocName) 33 | { 34 | return new self('Found a detached or new document at property '. 35 | $className.'::'.$assocName.' of document with ID '.$id.', '. 36 | 'but the assocation is not marked as cascade persist.'); 37 | } 38 | 39 | public static function persistRemovedDocument() 40 | { 41 | return new self('Trying to persist document that is scheduled for removal.'); 42 | } 43 | 44 | public static function luceneNotConfigured() 45 | { 46 | return new self('CouchDB Lucene is not configured. You have to configure the handler name to enable support for Lucene Queries.'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Utils/BulkUpdater.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class BulkUpdater 18 | { 19 | private $data = ['docs' => []]; 20 | 21 | private $requestHeaders = []; 22 | 23 | private $httpClient; 24 | 25 | private $databaseName; 26 | 27 | public function __construct(Client $httpClient, $databaseName) 28 | { 29 | $this->httpClient = $httpClient; 30 | $this->databaseName = $databaseName; 31 | } 32 | 33 | public function updateDocument($data) 34 | { 35 | $this->data['docs'][] = $data; 36 | } 37 | 38 | public function updateDocuments(array $docs) 39 | { 40 | foreach ($docs as $doc) { 41 | $this->data['docs'][] = (is_array($doc) ? $doc : json_decode($doc, true)); 42 | } 43 | } 44 | 45 | public function deleteDocument($id, $rev) 46 | { 47 | $this->data['docs'][] = ['_id' => $id, '_rev' => $rev, '_deleted' => true]; 48 | } 49 | 50 | public function setNewEdits($newEdits) 51 | { 52 | $this->data['new_edits'] = (bool) $newEdits; 53 | } 54 | 55 | public function setFullCommitHeader($commit) 56 | { 57 | $this->requestHeaders['X-Couch-Full-Commit'] = (bool) $commit; 58 | } 59 | 60 | public function execute() 61 | { 62 | return $this->httpClient->request('POST', $this->getPath(), json_encode($this->data), false, $this->requestHeaders); 63 | } 64 | 65 | public function getPath() 66 | { 67 | return '/'.$this->databaseName.'/_bulk_docs'; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/CouchDBFunctionalTestCase.php: -------------------------------------------------------------------------------- 1 | createCouchDBClient()->deleteDatabase($this->getTestDatabase()); 17 | } 18 | 19 | /** 20 | * @return \Doctrine\CouchDB\HTTP\Client 21 | */ 22 | public function getHttpClient() 23 | { 24 | if ($this->httpClient === null) { 25 | if (isset($GLOBALS['DOCTRINE_COUCHDB_CLIENT'])) { 26 | $this->httpClient = new $GLOBALS['DOCTRINE_COUCHDB_CLIENT'](); 27 | } else { 28 | $this->httpClient = new SocketClient(); 29 | } 30 | } 31 | 32 | return $this->httpClient; 33 | } 34 | 35 | public function getTestDatabase() 36 | { 37 | return TestUtil::getTestDatabase(); 38 | } 39 | 40 | public function getBulkTestDatabase() 41 | { 42 | return TestUtil::getBulkTestDatabase(); 43 | } 44 | 45 | public function createCouchDBClient() 46 | { 47 | return new CouchDBClient($this->getHttpClient(), $this->getTestDatabase()); 48 | } 49 | 50 | public function createMangoClient(){ 51 | return new MangoClient($this->getHttpClient(), $this->getTestDatabase()); 52 | } 53 | 54 | public function createCouchDBClientForBulkTest() 55 | { 56 | return new CouchDBClient($this->getHttpClient(), $this->getBulkTestDatabase()); 57 | } 58 | 59 | public function createMangoClientForBulkTest() 60 | { 61 | return new MangoClient($this->getHttpClient(), $this->getBulkTestDatabase()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Console/Command/ReplicationCancelCommand.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:replication:cancel') 16 | ->setDescription('Cancel replication from a given source to target.') 17 | ->setDefinition([ 18 | new InputArgument('source', InputArgument::REQUIRED, 'Source Database', null), 19 | new InputArgument('target', InputArgument::REQUIRED, 'Target Database', null), 20 | new InputOption('continuous', 'c', InputOption::VALUE_NONE, 'Enable continuous replication', null), 21 | ])->setHelp(<<<'EOT' 22 | With this command you cancel the replication between a given source and target. 23 | All the options to POST /db/_replicate are available. Example usage: 24 | 25 | doctrine-couchdb couchdb:replication:cancel example-source-db example-target-db 26 | doctrine-couchdb couchdb:replication:cancel example-source-db http://example.com:5984/example-target-db 27 | 28 | EOT 29 | ); 30 | } 31 | 32 | protected function execute(InputInterface $input, OutputInterface $output) 33 | { 34 | $couchClient = $this->getHelper('couchdb')->getCouchDBClient(); 35 | /* @var $couchClient \Doctrine\CouchDB\CouchDBClient */ 36 | $data = $couchClient->replicate( 37 | $input->getArgument('source'), 38 | $input->getArgument('target'), 39 | true, 40 | $input->getOption('continuous') ? true : false 41 | ); 42 | 43 | $output->writeln('Replication canceled.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/FolderDesignDocument.php: -------------------------------------------------------------------------------- 1 | folderPath = realpath($folderPath); 20 | } 21 | 22 | public function getData() 23 | { 24 | if ($this->data === null) { 25 | $rdi = new \RecursiveDirectoryIterator($this->folderPath, \FilesystemIterator::CURRENT_AS_FILEINFO); 26 | $ri = new \RecursiveIteratorIterator($rdi, \RecursiveIteratorIterator::LEAVES_ONLY); 27 | 28 | $this->data = []; 29 | foreach ($ri as $path) { 30 | $fileData = $this->getFileData($path); 31 | if ($fileData !== null) { 32 | $parts = explode(DIRECTORY_SEPARATOR, ltrim(str_replace($this->folderPath, '', $fileData['key']), DIRECTORY_SEPARATOR)); 33 | 34 | if (count($parts) == 3) { 35 | $this->data[$parts[0]][$parts[1]][$parts[2]] = $fileData['data']; 36 | } elseif (count($parts) == 2) { 37 | $this->data[$parts[0]][$parts[1]] = $fileData['data']; 38 | } elseif (count($parts) == 1) { 39 | $this->data[$parts[0]] = $fileData['data']; 40 | } 41 | } 42 | } 43 | 44 | $this->data['language'] = 'javascript'; 45 | } 46 | 47 | return $this->data; 48 | } 49 | 50 | private function getFileData($path) 51 | { 52 | $result = null; 53 | if (substr($path, -3) === '.js') { 54 | $result = ['key' => str_replace('.js', '', $path), 55 | 'data'=> file_get_contents($path), ]; 56 | } elseif (substr($path, -5) === '.json') { 57 | $result = ['key' => str_replace('.json', '', $path), 58 | 'data'=> json_decode(file_get_contents($path), true), ]; 59 | } 60 | 61 | return $result; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Tools/Console/Command/ReplicationStartCommand.php: -------------------------------------------------------------------------------- 1 | setName('couchdb:replication:start') 16 | ->setDescription('Start replication from a given source to target.') 17 | ->setDefinition([ 18 | new InputArgument('source', InputArgument::REQUIRED, 'Source Database', null), 19 | new InputArgument('target', InputArgument::REQUIRED, 'Target Database', null), 20 | new InputOption('continuous', 'c', InputOption::VALUE_NONE, 'Enable continuous replication', null), 21 | new InputOption('proxy', 'p', InputOption::VALUE_REQUIRED, 'Proxy server to replicate through', null), 22 | new InputOption('id', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Ids for named replication', null), 23 | new InputOption('filter', 'f', InputOption::VALUE_REQUIRED, 'Replication-Filter Document', null), 24 | ])->setHelp(<<<'EOT' 25 | With this command you start the replication between a given source and target. 26 | All the options to POST /db/_replicate are available. Example usage: 27 | 28 | doctrine-couchdb couchdb:replication:start example-source-db example-target-db 29 | doctrine-couchdb couchdb:replication:start example-source-db http://example.com:5984/example-target-db 30 | 31 | EOT 32 | ); 33 | } 34 | 35 | protected function execute(InputInterface $input, OutputInterface $output) 36 | { 37 | $couchClient = $this->getHelper('couchdb')->getCouchDBClient(); 38 | /* @var $couchClient \Doctrine\CouchDB\CouchDBClient */ 39 | $data = $couchClient->replicate( 40 | $input->getArgument('source'), 41 | $input->getArgument('target'), null, 42 | $input->getOption('continuous') ? true : false, 43 | $input->getOption('filter') ?: null, 44 | $input->getOption('id') ?: null, 45 | $input->getOption('proxy') ?: null 46 | ); 47 | 48 | $output->writeln('Replication started.'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/HTTPException.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class HTTPException extends \Doctrine\CouchDB\CouchDBException 16 | { 17 | /** 18 | * @param string $ip 19 | * @param int $port 20 | * @param string $errstr 21 | * @param int $errno 22 | * 23 | * @return \Doctrine\CouchDB\HTTP\HTTPException 24 | */ 25 | public static function connectionFailure($ip, $port, $errstr, $errno) 26 | { 27 | return new self(sprintf( 28 | "Could not connect to server at %s:%d: '%d: %s'", 29 | $ip, 30 | $port, 31 | $errno, 32 | $errstr 33 | ), $errno); 34 | } 35 | 36 | /** 37 | * @param string $ip 38 | * @param int $port 39 | * @param string $errstr 40 | * @param int $errno 41 | * 42 | * @return \Doctrine\CouchDB\HTTP\HTTPException 43 | */ 44 | public static function readFailure($ip, $port, $errstr, $errno) 45 | { 46 | return new static(sprintf( 47 | "Could read from server at %s:%d: '%d: %s'", 48 | $ip, 49 | $port, 50 | $errno, 51 | $errstr 52 | ), $errno); 53 | } 54 | 55 | /** 56 | * @param string $path 57 | * @param Response $response 58 | * 59 | * @return \Doctrine\CouchDB\HTTP\HTTPException 60 | */ 61 | public static function fromResponse($path, Response $response) 62 | { 63 | $response = self::fixCloudantBulkCustomError($response); 64 | 65 | if (!isset($response->body['error'])) { 66 | $response->body['error'] = ''; 67 | } 68 | 69 | if (!isset($response->body['reason'])) { 70 | $response->body['reason'] = ''; 71 | } 72 | 73 | return new self( 74 | 'HTTP Error with status '.$response->status.' occurred while ' 75 | .'requesting '.$path.'. Error: '.$response->body['error'] 76 | .' '.$response->body['reason'], 77 | $response->status); 78 | } 79 | 80 | private static function fixCloudantBulkCustomError($response) 81 | { 82 | if (isset($response->body[0]['error']) && isset($response->body[0]['reason'])) { 83 | $response->body['error'] = $response->body[0]['error']; 84 | $response->body['reason'] = $response->body[0]['reason']; 85 | } 86 | 87 | return $response; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/LoggingClient.php: -------------------------------------------------------------------------------- 1 | client = $client; 42 | } 43 | 44 | public function request($method, $path, $data = null, $raw = false, array $headers = []) 45 | { 46 | $start = microtime(true); 47 | 48 | $response = $this->client->request($method, $path, $data, $raw, $headers); 49 | 50 | $duration = microtime(true) - $start; 51 | $this->requests[] = [ 52 | 'duration' => $duration, 53 | 'method' => $method, 54 | 'path' => rawurldecode($path), 55 | 'request' => $data, 56 | 'request_size' => strlen($data), 57 | 'response_status' => $response->status, 58 | 'response' => $response->body, 59 | 'response_headers' => $response->headers, 60 | ]; 61 | $this->totalDuration += $duration; 62 | 63 | return $response; 64 | } 65 | 66 | public function getConnection( 67 | $method, 68 | $path, 69 | $data = null, 70 | array $headers = [] 71 | ) { 72 | $start = microtime(true); 73 | 74 | $response = $this->client->getConnection( 75 | $method, 76 | $path, 77 | $data, 78 | $headers 79 | ); 80 | 81 | $duration = microtime(true) - $start; 82 | $this->requests[] = [ 83 | 'duration' => $duration, 84 | 'method' => $method, 85 | 'path' => rawurldecode($path), 86 | 'request' => $data, 87 | 'request_size' => strlen($data), 88 | 'response_status' => $response->status, 89 | 'response' => $response->body, 90 | 'response_headers' => $response->headers, 91 | ]; 92 | $this->totalDuration += $duration; 93 | 94 | return $response; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Mango/MangoQuery.php: -------------------------------------------------------------------------------- 1 | selector = $selector; 19 | $this->options = $options; 20 | 21 | if (isset($this->options['fields'])) { 22 | $this->fields = $this->options['fields']; 23 | } 24 | 25 | if (isset($this->options['sort'])) { 26 | $this->sort = $this->options['sort']; 27 | } 28 | 29 | if (isset($this->options['skip'])) { 30 | $this->skip = $this->options['skip']; 31 | } 32 | 33 | if (isset($this->options['limit'])) { 34 | $this->limit = $this->options['limit']; 35 | } 36 | 37 | if (isset($this->options['use_index'])) { 38 | $this->use_index = $this->options['use_index']; 39 | } 40 | 41 | } 42 | 43 | public function where(array $selector){ 44 | return $this->selector($selector); 45 | } 46 | 47 | public function selector(array $selector = null){ 48 | if($selector !== null){ 49 | $this->selector = $selector; 50 | return $this; 51 | }else{ 52 | return $this->selector; 53 | } 54 | } 55 | 56 | public function select(array $fields){ 57 | return $this->fields($fields); 58 | } 59 | 60 | public function fields(array $fields = null){ 61 | if($fields !== null){ 62 | $this->fields = $fields; 63 | return $this; 64 | }else{ 65 | return $this->fields; 66 | } 67 | } 68 | 69 | public function sort(array $sort = null){ 70 | if($sort !== null){ 71 | $this->sort = $sort; 72 | return $this; 73 | }else{ 74 | return $this->sort; 75 | } 76 | } 77 | 78 | public function skip($skip = null){ 79 | if($skip !== null){ 80 | $this->skip = $skip; 81 | return $this; 82 | }else{ 83 | return $this->skip; 84 | } 85 | } 86 | 87 | public function use_index(array $use_index = null){ 88 | if($use_index !== null){ 89 | $this->use_index = $use_index; 90 | return $this; 91 | }else{ 92 | return $this->use_index; 93 | } 94 | } 95 | 96 | public function limit($limit = null){ 97 | if($limit !== null){ 98 | $this->limit = $limit; 99 | return $this; 100 | }else{ 101 | return $this->limit; 102 | } 103 | } 104 | 105 | public function asArray(){ 106 | 107 | $params = array(); 108 | $params['selector'] = ($this->selector) ? $this->selector : new \StdClass(); 109 | 110 | if ($this->fields) { 111 | $params['fields'] = $this->fields; 112 | } 113 | 114 | if ($this->sort) { 115 | $params['sort'] = $this->sort; 116 | } 117 | 118 | if ($this->skip) { 119 | $params['skip'] = $this->skip; 120 | } 121 | 122 | if ($this->limit) { 123 | $params['limit'] = $this->limit; 124 | } 125 | 126 | if ($this->use_index) { 127 | $params['use_index'] = $this->use_index; 128 | } 129 | 130 | return $params; 131 | } 132 | 133 | public function jsonSerialize() { 134 | return $this->asArray(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/AbstractHTTPClient.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | abstract class AbstractHTTPClient implements Client 19 | { 20 | /** 21 | * CouchDB connection options. 22 | * 23 | * @var array 24 | */ 25 | protected $options = [ 26 | 'host' => 'localhost', 27 | 'port' => 5984, 28 | 'ip' => '127.0.0.1', 29 | 'ssl' => false, 30 | 'timeout' => 10, 31 | 'keep-alive' => true, 32 | 'username' => null, 33 | 'password' => null, 34 | 'path' => null, 35 | 'headers' => [], 36 | ]; 37 | 38 | /** 39 | * Construct a CouchDB connection. 40 | * 41 | * Construct a CouchDB connection from basic connection parameters for one 42 | * given database. 43 | * 44 | * @param string $host 45 | * @param int $port 46 | * @param string $username 47 | * @param string $password 48 | * @param string $ip 49 | * @param bool $ssl 50 | * @param string $path 51 | * @param int $timeout 52 | * @param array $headers 53 | * 54 | * @return \Doctrine\CouchDB\HTTP\AbstractHTTPClient 55 | */ 56 | public function __construct($host = 'localhost', $port = 5984, $username = null, $password = null, $ip = null, $ssl = false, $path = null, $timeout = 10, array $headers = []) 57 | { 58 | $this->options['host'] = (string) $host; 59 | $this->options['port'] = (int) $port; 60 | $this->options['ssl'] = $ssl; 61 | $this->options['username'] = $username; 62 | $this->options['password'] = $password; 63 | $this->options['path'] = $path; 64 | $this->options['timeout'] = (float) $timeout; 65 | $this->options['headers'] = $headers; 66 | 67 | if ($ip === null) { 68 | $this->options['ip'] = gethostbyname($this->options['host']); 69 | } else { 70 | $this->options['ip'] = $ip; 71 | } 72 | } 73 | 74 | /** 75 | * Set option value. 76 | * 77 | * Set the value for an connection option. Throws an 78 | * InvalidArgumentException for unknown options. 79 | * 80 | * @param string $option 81 | * @param mixed $value 82 | * 83 | * @throws \InvalidArgumentException 84 | * 85 | * @return void 86 | */ 87 | public function setOption($option, $value) 88 | { 89 | switch ($option) { 90 | case 'keep-alive': 91 | case 'ssl': 92 | $this->options[$option] = (bool) $value; 93 | break; 94 | 95 | case 'http-log': 96 | case 'headers': 97 | case 'password': 98 | case 'username': 99 | $this->options[$option] = $value; 100 | break; 101 | 102 | default: 103 | throw new \InvalidArgumentException("Unknown option $option."); 104 | } 105 | } 106 | 107 | /** 108 | * Get the connection options. 109 | * 110 | * @return array 111 | */ 112 | public function getOptions() 113 | { 114 | return $this->options; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/LuceneQuery.php: -------------------------------------------------------------------------------- 1 | handlerName = $handlerName; 28 | } 29 | 30 | protected function getHttpQuery() 31 | { 32 | return sprintf( 33 | '/%s/local/%s/_design/%s/%s?%s', 34 | $this->handlerName, 35 | $this->databaseName, 36 | $this->designDocumentName, 37 | $this->viewName, 38 | http_build_query($this->params) 39 | ); 40 | } 41 | 42 | public function setAnalyzer($analyzer) 43 | { 44 | $this->params['analyzer'] = $analyzer; 45 | 46 | return $this; 47 | } 48 | 49 | public function getAnalyzer() 50 | { 51 | return (isset($this->params['analyzer'])) ? $this->params['analyzer'] : null; 52 | } 53 | 54 | /** 55 | * Automatically fetch and include the document which emitted each view entry. 56 | * 57 | * @param bool $flag 58 | * 59 | * @return Query 60 | */ 61 | public function setIncludeDocs($flag) 62 | { 63 | $this->params['include_docs'] = $flag; 64 | 65 | return $this; 66 | } 67 | 68 | public function getIncludeDocs() 69 | { 70 | return (isset($this->params['include_docs'])) ? $this->params['include_docs'] : null; 71 | } 72 | 73 | public function setLimit($limit) 74 | { 75 | $this->params['limit'] = $limit; 76 | 77 | return $this; 78 | } 79 | 80 | public function getLimit() 81 | { 82 | return (isset($this->params['limit'])) ? $this->params['limit'] : null; 83 | } 84 | 85 | public function setQuery($query) 86 | { 87 | $this->params['q'] = $query; 88 | 89 | return $this; 90 | } 91 | 92 | public function getQuery() 93 | { 94 | return isset($this->params['q']) ? $this->params['q'] : null; 95 | } 96 | 97 | public function setSkip($skip) 98 | { 99 | $this->params['skip'] = $skip; 100 | 101 | return $this; 102 | } 103 | 104 | public function setSort($sort) 105 | { 106 | $this->params['sort'] = $sort; 107 | 108 | return $this; 109 | } 110 | 111 | public function setStale($bool) 112 | { 113 | if ($bool) { 114 | $this->params['stale'] = 'ok'; 115 | } else { 116 | unset($this->params['stale']); 117 | } 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * @param \Doctrine\CouchDB\HTTP\Response $response 124 | * 125 | * @return LuceneResult 126 | */ 127 | protected function createResult($response) 128 | { 129 | return new LuceneResult($response->body); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/AbstractQuery.php: -------------------------------------------------------------------------------- 1 | client = $client; 50 | $this->databaseName = $databaseName; 51 | $this->designDocumentName = $designDocName; 52 | $this->viewName = $viewName; 53 | $this->doc = $doc; 54 | } 55 | 56 | /** 57 | * @param string $key 58 | * 59 | * @return mixed 60 | */ 61 | public function getParameter($key) 62 | { 63 | if (isset($this->params[$key])) { 64 | return $this->params[$key]; 65 | } 66 | } 67 | 68 | abstract protected function getHttpQuery(); 69 | 70 | /** 71 | * Query the view with the current params. 72 | * 73 | * @return Result 74 | */ 75 | public function execute() 76 | { 77 | return $this->createResult($this->doExecute()); 78 | } 79 | 80 | protected function doExecute() 81 | { 82 | $path = $this->getHttpQuery(); 83 | $method = 'GET'; 84 | $data = null; 85 | 86 | if ($this->getParameter('keys') !== null) { 87 | $method = 'POST'; 88 | $data = json_encode(['keys' => $this->getParameter('keys')]); 89 | } 90 | 91 | $response = $this->client->request($method, $path, $data); 92 | 93 | if ($response instanceof ErrorResponse && $this->doc) { 94 | $this->createDesignDocument(); 95 | $response = $this->client->request($method, $path, $data); 96 | } 97 | 98 | if ($response->status >= 400) { 99 | throw HTTPException::fromResponse($path, $response); 100 | } 101 | 102 | return $response; 103 | } 104 | 105 | /** 106 | * @param $response 107 | * 108 | * @return Result 109 | */ 110 | abstract protected function createResult($response); 111 | 112 | /** 113 | * Create non existing view. 114 | * 115 | * @throws \Doctrine\CouchDB\JsonDecodeException 116 | * @throws \Exception 117 | * 118 | * @return void 119 | */ 120 | public function createDesignDocument() 121 | { 122 | if (!$this->doc) { 123 | throw new \Exception('No DesignDocument Class is connected to this view query, cannot create the design document with its corresponding view automatically!'); 124 | } 125 | 126 | $data = $this->doc->getData(); 127 | if ($data === null) { 128 | throw \Doctrine\CouchDB\JsonDecodeException::fromLastJsonError(); 129 | } 130 | $data['_id'] = '_design/'.$this->designDocumentName; 131 | 132 | $response = $this->client->request( 133 | 'PUT', 134 | sprintf( 135 | '/%s/_design/%s', 136 | $this->databaseName, 137 | $this->designDocumentName 138 | ), 139 | json_encode($data) 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/CouchDBClientTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("\xEF\xBF\xB0", CouchDBClient::COLLATION_END); 13 | } 14 | 15 | public function testCreateClient() 16 | { 17 | $client = CouchDBClient::create(['dbname' => 'test']); 18 | $this->assertEquals('test', $client->getDatabase()); 19 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Client', $client->getHttpClient()); 20 | 21 | $httpClient = new SocketClient(); 22 | $client->setHttpClient($httpClient); 23 | $this->assertEquals($httpClient, $client->getHttpClient()); 24 | } 25 | 26 | public function testCreateClientFromUrl() 27 | { 28 | $client = CouchDBClient::create(['url' => 'https://foo:bar@localhost:5555/baz']); 29 | 30 | $this->assertEquals('baz', $client->getDatabase()); 31 | $this->assertEquals( 32 | [ 33 | 'host' => 'localhost', 34 | 'port' => 5555, 35 | 'ip' => '127.0.0.1', 36 | 'username' => 'foo', 37 | 'password' => 'bar', 38 | 'ssl' => true, 39 | 'timeout' => 10, 40 | 'keep-alive' => true, 41 | 'path' => null, 42 | 'headers' => [], 43 | ], 44 | $client->getHttpClient()->getOptions() 45 | ); 46 | } 47 | 48 | public function testCreateClientFromUrlWithPath() 49 | { 50 | $client = CouchDBClient::create(['url' => 'https://foo:bar@localhost:5555/baz/qux/norf']); 51 | 52 | $this->assertEquals('norf', $client->getDatabase()); 53 | $this->assertEquals( 54 | [ 55 | 'host' => 'localhost', 56 | 'port' => 5555, 57 | 'ip' => '127.0.0.1', 58 | 'username' => 'foo', 59 | 'password' => 'bar', 60 | 'ssl' => true, 61 | 'timeout' => 10, 62 | 'keep-alive' => true, 63 | 'path' => 'baz/qux', 64 | 'headers' => [], 65 | ], 66 | $client->getHttpClient()->getOptions() 67 | ); 68 | } 69 | 70 | public function testCreateClientWithDefaultHeaders() 71 | { 72 | $client = CouchDBClient::create(['dbname' => 'test', 'headers' => ['X-Test' => 'test']]); 73 | $http_client = $client->getHttpClient(); 74 | $connection_options = $http_client->getOptions(); 75 | $this->assertSame(['X-Test' => 'test'], $connection_options['headers']); 76 | 77 | $http_client->setOption('headers', ['X-Test-New' => 'new']); 78 | $connection_options = $http_client->getOptions(); 79 | $this->assertSame(['X-Test-New' => 'new'], $connection_options['headers']); 80 | } 81 | 82 | public function testCreateClientWithLogging() 83 | { 84 | $client = CouchDBClient::create(['dbname' => 'test', 'logging' => true]); 85 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\LoggingClient', $client->getHttpClient()); 86 | } 87 | 88 | /** 89 | * @expectedException \InvalidArgumentException 90 | * @expectedExceptionMessage 'dbname' is a required option to create a CouchDBClient 91 | */ 92 | public function testCreateClientDBNameException() 93 | { 94 | CouchDBClient::create([]); 95 | } 96 | 97 | /** 98 | * @expectedException \InvalidArgumentException 99 | * @expectedExceptionMessage There is no client implementation registered for foo, valid options are: socket, stream 100 | */ 101 | public function testCreateClientMissingClientException() 102 | { 103 | CouchDBClient::create(['dbname' => 'test', 'type' => 'foo']); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/Functional/BulkUpdaterTest.php: -------------------------------------------------------------------------------- 1 | couchClient = $this->createCouchDBClientForBulkTest(); 23 | $this->couchClient->createDatabase($this->getBulkTestDatabase()); 24 | $this->bulkUpdater = $this->couchClient->createBulkUpdater(); 25 | } 26 | 27 | public function testGetPath() 28 | { 29 | $this->assertEquals( 30 | '/'.$this->getBulkTestDatabase().'/_bulk_docs', 31 | $this->bulkUpdater->getpath() 32 | ); 33 | } 34 | 35 | /** 36 | * @depends testGetPath 37 | */ 38 | public function testExecute() 39 | { 40 | $response = $this->bulkUpdater->execute(); 41 | $this->assertEquals(201, $response->status); 42 | $this->assertEquals([], $response->body); 43 | } 44 | 45 | /** 46 | * @depends testExecute 47 | */ 48 | public function testSetNewEdits() 49 | { 50 | $this->bulkUpdater->setNewEdits(false); 51 | $doc = ['_id' => 'test1', 'foo' => 'bar', '_rev' => '10-gsoc']; 52 | $this->bulkUpdater->updateDocument($doc); 53 | $response = $this->bulkUpdater->execute(); 54 | $response = $this->couchClient->findDocument('test1'); 55 | // _rev remains same. 56 | $this->assertEquals($doc, $response->body); 57 | } 58 | 59 | /** 60 | * @depends testExecute 61 | */ 62 | public function testUpdateDocument() 63 | { 64 | $docs['test1'] = ['_id' => 'test1', 'foo' => 'bar']; 65 | $docs['test2'] = ['_id' => 'test2', 'bar' => 'baz']; 66 | $this->bulkUpdater->updateDocument($docs['test1']); 67 | $this->bulkUpdater->updateDocument($docs['test2']); 68 | $response = $this->bulkUpdater->execute(); 69 | 70 | // Insert the rev values. 71 | foreach ($response->body as $res) { 72 | $docs[$res['id']]['_rev'] = $res['rev']; 73 | } 74 | 75 | $response = $this->couchClient->findDocument('test1'); 76 | $this->assertEquals($docs['test1'], $response->body); 77 | $response = $this->couchClient->findDocument('test2'); 78 | $this->assertEquals($docs['test2'], $response->body); 79 | } 80 | 81 | /** 82 | * @depends testExecute 83 | */ 84 | public function testUpdateDocuments() 85 | { 86 | $docs[] = ['_id' => 'test1', 'foo' => 'bar']; 87 | $docs[] = '{"_id": "test2","baz": "foo"}'; 88 | 89 | $this->bulkUpdater->updateDocuments($docs); 90 | $response = $this->bulkUpdater->execute(); 91 | 92 | // Insert the rev values. 93 | foreach ($response->body as $res) { 94 | $id = $res['id']; 95 | if ($id == 'test1') { 96 | $docs[0]['_rev'] = $res['rev']; 97 | } elseif ($id == 'test2') { 98 | $docs[1] = substr($docs[1], 0, strlen($docs[1]) - 1).',"_rev": "'.$res['rev'].'"}'; 99 | } 100 | } 101 | 102 | $response = $this->couchClient->findDocument('test1'); 103 | $this->assertEquals($docs[0], $response->body); 104 | $response = $this->couchClient->findDocument('test2'); 105 | $this->assertEquals(json_decode($docs[1], true), $response->body); 106 | } 107 | 108 | /** 109 | * @depends testExecute 110 | */ 111 | public function testDeleteDocument() 112 | { 113 | $doc = ['_id' => 'test1', 'foo' => 'bar']; 114 | $this->bulkUpdater->updateDocument($doc); 115 | $response = $this->bulkUpdater->execute(); 116 | $rev = $response->body[0]['rev']; 117 | 118 | $bulkUpdater2 = $this->couchClient->createBulkUpdater(); 119 | $bulkUpdater2->deleteDocument('test1', $rev); 120 | $response = $bulkUpdater2->execute(); 121 | $response = $this->couchClient->findDocument('test1'); 122 | $this->assertEquals(404, $response->status); 123 | } 124 | 125 | public function tearDown() 126 | { 127 | $this->couchClient->deleteDatabase($this->getBulkTestDatabase()); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/Functional/MangoQueryTest.php: -------------------------------------------------------------------------------- 1 | ['$gt'=>null]]; 13 | $query = new MangoQuery($selector); 14 | $query = $query->asArray(); 15 | $this->assertEquals($selector,$query['selector']); 16 | } 17 | 18 | public function testEmptySelector(){ 19 | $query = new MangoQuery([]); 20 | $query = $query->asArray(); 21 | $this->assertInstanceOf('StdClass',$query['selector']); 22 | } 23 | 24 | public function testSelectorMethod(){ 25 | $query = new MangoQuery([]); 26 | $selector = ['id'=>['$gt'=>null]]; 27 | $query->selector($selector); 28 | $this->assertTrue(array_key_exists('selector',$query->asArray())); 29 | $this->assertEquals($selector,$query->selector()); 30 | } 31 | 32 | public function testSetEmptySelector(){ 33 | $query = new MangoQuery([]); 34 | $query->selector([]); 35 | $query = $query->asArray(); 36 | $this->assertInstanceOf('StdClass',$query['selector']); 37 | } 38 | 39 | public function testLimitOption(){ 40 | $query = new MangoQuery([],['limit'=>1]); 41 | $query = $query->asArray(); 42 | $this->assertTrue(array_key_exists('limit',$query)); 43 | $this->assertEquals(1,$query['limit']); 44 | } 45 | 46 | public function testLimitMethod(){ 47 | $query = new MangoQuery([]); 48 | $query->limit(1); 49 | $this->assertTrue(array_key_exists('limit',$query->asArray())); 50 | $this->assertEquals(1,$query->limit()); 51 | } 52 | 53 | public function testSkipOption(){ 54 | $query = new MangoQuery([],['skip'=>1]); 55 | $query = $query->asArray(); 56 | $this->assertTrue(array_key_exists('skip',$query)); 57 | $this->assertEquals(1,$query['skip']); 58 | } 59 | 60 | public function testSkipMethod(){ 61 | $query = new MangoQuery([]); 62 | $query->skip(1); 63 | $this->assertTrue(array_key_exists('skip',$query->asArray())); 64 | $this->assertEquals(1,$query->skip()); 65 | } 66 | 67 | public function testFieldsOption(){ 68 | $query = new MangoQuery([],['fields'=>['_id','_rev']]); 69 | $query = $query->asArray(); 70 | $this->assertTrue(array_key_exists('fields',$query)); 71 | $this->assertEquals(['_id','_rev'],$query['fields']); 72 | } 73 | 74 | public function testFieldsMethod(){ 75 | $query = new MangoQuery([]); 76 | $query->fields(['_id','_rev']); 77 | $this->assertTrue(array_key_exists('fields',$query->asArray())); 78 | $this->assertEquals(['_id','_rev'],$query->fields()); 79 | } 80 | 81 | public function testSortOption(){ 82 | $query = new MangoQuery([],['sort'=>[['name'=>'desc']]]); 83 | $query = $query->asArray(); 84 | $this->assertTrue(array_key_exists('sort',$query)); 85 | $this->assertEquals([['name'=>'desc']],$query['sort']); 86 | } 87 | 88 | public function testSortMethod(){ 89 | $query = new MangoQuery([]); 90 | $query->sort([['name'=>'desc']]); 91 | $this->assertTrue(array_key_exists('sort',$query->asArray())); 92 | $this->assertEquals([['name'=>'desc']],$query->sort()); 93 | } 94 | 95 | public function testUseIndexOption(){ 96 | $query = new MangoQuery([],['use_index'=>['document','index']]); 97 | $query = $query->asArray(); 98 | $this->assertTrue(array_key_exists('use_index',$query)); 99 | $this->assertEquals(['document','index'],$query['use_index']); 100 | } 101 | 102 | public function testUseIndexMethod(){ 103 | $query = new MangoQuery([]); 104 | $query->use_index(['document','index']); 105 | $this->assertTrue(array_key_exists('use_index',$query->asArray())); 106 | $this->assertEquals(['document','index'],$query->use_index()); 107 | } 108 | 109 | public function testJsonSerialize(){ 110 | $query = new MangoQuery(); 111 | 112 | $params = [ 113 | 'selector'=>['_id'=>['$gt'=>null]], 114 | 'fields'=>['_id'], 115 | 'sort'=>[['name'=>'desc']], 116 | 'skip'=>1, 117 | 'limit'=>10, 118 | 'use_index'=>['design','document'] 119 | ]; 120 | 121 | $query->select($params['fields'])->where($params['selector'])->limit($params['limit'])->skip($params['skip'])->sort($params['sort'])->use_index($params['use_index']); 122 | 123 | $this->assertEquals(json_encode($params),json_encode($query)); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doctrine CouchDB v2.x Client 2 | 3 | [![Build Status](https://travis-ci.org/doctrine/couchdb-client.png?branch=master)](https://travis-ci.org/doctrine/couchdb-client) 4 | [![StyleCI](https://styleci.io/repos/90809440/shield?style=flat)](https://styleci.io/repos/90809440) 5 | 6 | 7 | Simple API that wraps around CouchDBs v2.x HTTP API. 8 | 9 | For CouchDB 1.x, please check our [release/1.0.0](https://github.com/doctrine/couchdb-client/tree/release/1.0.0) branch. 10 | 11 | ## Features 12 | 13 | * Create, Delete, List Databases 14 | * Create, Update, Delete Documents 15 | * Bulk API for Creating/Updating Documents 16 | * Find Documents by ID 17 | * Generate UUIDs 18 | * Design Documents 19 | * Query `_all_docs` view 20 | * Query Changes Feed 21 | * Query Views 22 | * Compaction Info and Triggering APIs 23 | * Replication API 24 | * Symfony Console Commands 25 | * Find Documents using Mango Query 26 | 27 | ## Installation 28 | 29 | With Composer: 30 | 31 | { 32 | "require": { 33 | "doctrine/couchdb": "@dev" 34 | } 35 | } 36 | 37 | ## Usage 38 | 39 | ### Basic Operations 40 | 41 | Covering the basic CRUD Operations for databases and documents: 42 | 43 | ```php 44 | 'doctrine_example')); 46 | 47 | // Create a database. 48 | $client->createDatabase($client->getDatabase()); 49 | 50 | // Create a new document. 51 | list($id, $rev) = $client->postDocument(array('foo' => 'bar')); 52 | 53 | // Update a existing document. This will increment the revision. 54 | list($id, $rev) = $client->putDocument(array('foo' => 'baz'), $id, $rev); 55 | 56 | // Fetch single document by id. 57 | $doc = $client->findDocument($id); 58 | 59 | // Fetch multiple documents at once. 60 | $docs = $client->findDocuments(array($id)); 61 | 62 | // Return all documents from database (_all_docs?include_docs=true). 63 | $allDocs = $client->allDocs(); 64 | 65 | // Delete a single document. 66 | $client->deleteDocument($id, $rev); 67 | 68 | // Delete a database. 69 | $client->deleteDatabase($client->getDatabase()); 70 | 71 | //Search documents using Mango Query CouchDB v2.x 72 | 73 | $selector = ['_id'=>['$gt'=>null]]; 74 | $options = ['limit'=>1,'skip'=>1,'use_index'=>['_design/doc','index'],'sort'=>[['_id'=>'desc']]]; 75 | $query = new \Doctrine\CouchDB\Mango\MangoQuery($selector,$options); 76 | $docs = $client->find($query); 77 | 78 | $query = new \Doctrine\CouchDB\Mango\MangoQuery(); 79 | $query->select(['_id', 'name'])->where(['$and'=> [ 80 | [ 81 | 'name'=> [ 82 | '$eq'=> 'Under the Dome', 83 | ], 84 | 'genres'=> [ 85 | '$in'=> ['Drama','Comedy'], 86 | ], 87 | ], 88 | ])->sort([['_id'=>'desc']])->limit(1)->skip(1)->use_index(['_design/doc','index']); 89 | $docs = $client->find($query); 90 | 91 | ``` 92 | 93 | ### Views 94 | 95 | A simple example demonstrating how to create views and query them: 96 | 97 | ```php 98 | 'javascript', 105 | 'views' => array( 106 | 'by_author' => array( 107 | 'map' => 'function(doc) { 108 | if(\'article\' == doc.type) { 109 | emit(doc.author, doc._id); 110 | } 111 | }', 112 | 'reduce' => '_count' 113 | ), 114 | ), 115 | ); 116 | } 117 | } 118 | 119 | $client->createDesignDocument('articles', new ArticlesDesignDocument()); 120 | 121 | // Fill database with some data. 122 | foreach (array('Alice', 'Bob', 'Bob') as $author) { 123 | $client->postDocument(array( 124 | 'type' => 'article', 125 | 'author' => $author, 126 | 'content' => 'Lorem ipsum' 127 | )); 128 | } 129 | 130 | // Query all articles. 131 | $query = $client->createViewQuery('articles', 'by_author'); 132 | $query->setReduce(false); 133 | $query->setIncludeDocs(true); 134 | $result = $query->execute(); 135 | foreach ($result as $row) { 136 | $doc = $row['doc']; 137 | echo 'Article by ', $doc['author'], ': ', $doc['content'], "\n"; 138 | } 139 | // Article by Alice: Lorem ipsum 140 | // Article by Bob: Lorem ipsum 141 | // Article by Bob: Lorem ipsum 142 | 143 | 144 | // Query all articles written by bob. 145 | $query = $client->createViewQuery('articles', 'by_author'); 146 | $query->setKey('Bob'); 147 | // ... 148 | 149 | 150 | // Query the _count of articles each author has written. 151 | $query = $client->createViewQuery('articles', 'by_author'); 152 | $query->setReduce(true); 153 | $query->setGroupLevel(1); // group_level=1 means grouping by author. 154 | $result = $query->execute(); 155 | foreach ($result as $row) { 156 | echo 'Author ', $row['key'], ' has written ', $row['value'], ' articles', "\n"; 157 | } 158 | // Author Alice has written 1 articles 159 | // Author Bob has written 2 articles 160 | ``` 161 | -------------------------------------------------------------------------------- /docs/en/index.rst: -------------------------------------------------------------------------------- 1 | Doctrine CouchDB Client 2 | ======================= 3 | 4 | Doctrine CouchDB Client is a simple API that wraps around CouchDBs v2.x HTTP API. 5 | 6 | For CouchDB 1.x, please use our 7 | `release/1.0.0 `_ 8 | branch. 9 | 10 | Features 11 | -------- 12 | 13 | - Create, Delete, List Databases 14 | - Create, Update, Delete Documents 15 | - Bulk API for Creating/Updating Documents 16 | - Find Documents by ID 17 | - Generate UUIDs 18 | - Design Documents 19 | - Query ``_all_docs`` view 20 | - Query Changes Feed 21 | - Query Views 22 | - Compaction Info and Triggering APIs 23 | - Replication API 24 | - Symfony Console Commands 25 | - Find Documents using Mango Query 26 | 27 | Installation 28 | ------------ 29 | 30 | With Composer: 31 | 32 | .. code-block:: console 33 | 34 | composer require doctrine/couchdb 35 | 36 | Usage 37 | ----- 38 | 39 | Basic Operations 40 | ~~~~~~~~~~~~~~~~ 41 | 42 | Covering the basic CRUD Operations for databases and documents: 43 | 44 | .. code-block:: php 45 | 46 | 'doctrine_example']); 55 | 56 | // Create a database. 57 | $client->createDatabase($client->getDatabase()); 58 | 59 | // Create a new document. 60 | list($id, $rev) = $client->postDocument(['foo' => 'bar']); 61 | 62 | // Update a existing document. This will increment the revision. 63 | list($id, $rev) = $client->putDocument(['foo' => 'baz'], $id, $rev); 64 | 65 | // Fetch single document by id. 66 | $doc = $client->findDocument($id); 67 | 68 | // Fetch multiple documents at once. 69 | $docs = $client->findDocuments([$id]); 70 | 71 | // Return all documents from database (_all_docs?include_docs=true). 72 | $allDocs = $client->allDocs(); 73 | 74 | // Delete a single document. 75 | $client->deleteDocument($id, $rev); 76 | 77 | // Delete a database. 78 | $client->deleteDatabase($client->getDatabase()); 79 | 80 | // Search documents using Mango Query CouchDB v2.x 81 | 82 | $selector = ['_id' => ['$gt' => null]]; 83 | $options = ['limit' => 1,'skip' => 1,'use_index' => ['_design/doc','index'],'sort' => [['_id' => 'desc']]]; 84 | $query = new MangoQuery($selector, $options); 85 | $docs = $client->find($query); 86 | 87 | $query = new MangoQuery(); 88 | $query->select(['_id', 'name'])->where(['$and'=> [ 89 | [ 90 | 'name'=> ['$eq' => 'Under the Dome'], 91 | 'genres'=> [ 92 | '$in'=> ['Drama','Comedy'], 93 | ], 94 | ], 95 | ]) 96 | ->sort([['_id' => 'desc']]) 97 | ->limit(1) 98 | ->skip(1) 99 | ->use_index(['_design/doc', 'index']); 100 | 101 | $docs = $client->find($query); 102 | 103 | Views 104 | ~~~~~ 105 | 106 | A simple example demonstrating how to create views and query them: 107 | 108 | .. code-block:: php 109 | 110 | class ArticlesDesignDocument implements DesignDocument 111 | { 112 | public function getData() 113 | { 114 | return [ 115 | 'language' => 'javascript', 116 | 'views' => [ 117 | 'by_author' => [ 118 | 'map' => "function(doc) { 119 | if('article' == doc.type) { 120 | emit(doc.author, doc._id); 121 | } 122 | }", 123 | 'reduce' => '_count', 124 | ], 125 | ], 126 | ]; 127 | } 128 | } 129 | 130 | $client->createDesignDocument('articles', new ArticlesDesignDocument()); 131 | 132 | // Fill database with some data. 133 | foreach (['Alice', 'Bob', 'Bob'] as $author) { 134 | $client->postDocument([ 135 | 'type' => 'article', 136 | 'author' => $author, 137 | 'content' => 'Lorem ipsum', 138 | ]); 139 | } 140 | 141 | // Query all articles. 142 | $query = $client->createViewQuery('articles', 'by_author'); 143 | $query->setReduce(false); 144 | $query->setIncludeDocs(true); 145 | $result = $query->execute(); 146 | foreach ($result as $row) { 147 | $doc = $row['doc']; 148 | echo 'Article by ', $doc['author'], ': ', $doc['content'], "\n"; 149 | } 150 | 151 | // Article by Alice: Lorem ipsum 152 | // Article by Bob: Lorem ipsum 153 | // Article by Bob: Lorem ipsum 154 | 155 | // Query all articles written by bob. 156 | $query = $client->createViewQuery('articles', 'by_author'); 157 | $query->setKey('Bob'); 158 | // ... 159 | 160 | 161 | // Query the _count of articles each author has written. 162 | $query = $client->createViewQuery('articles', 'by_author'); 163 | $query->setReduce(true); 164 | $query->setGroupLevel(1); // group_level=1 means grouping by author. 165 | $result = $query->execute(); 166 | foreach ($result as $row) { 167 | echo 'Author ', $row['key'], ' has written ', $row['value'], ' articles', "\n"; 168 | } 169 | 170 | // Author Alice has written 1 articles 171 | // Author Bob has written 2 articles 172 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/View/Query.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class Query extends AbstractQuery 16 | { 17 | /** 18 | * Only a subset of parameters in the Query String must be JSON encoded when transmitted. 19 | * 20 | * @param array 21 | */ 22 | private static $encodeParams = ['key' => true, 'keys' => true, 'startkey' => true, 'endkey' => true]; 23 | 24 | protected function createResult($response) 25 | { 26 | return new Result($response->body); 27 | } 28 | 29 | /** 30 | * Encode HTTP Query String for View correctly with the following rules in mind. 31 | * 32 | * 1. Params "key", "keys", "startkey" or "endkey" must be json encoded. 33 | * 2. Booleans must be converted to "true" or "false" 34 | * 35 | * @return string 36 | */ 37 | protected function getHttpQuery() 38 | { 39 | $arguments = []; 40 | 41 | foreach ($this->params as $key => $value) { 42 | if (isset(self::$encodeParams[$key])) { 43 | $arguments[$key] = json_encode($value); 44 | } elseif (is_bool($value)) { 45 | $arguments[$key] = $value ? 'true' : 'false'; 46 | } else { 47 | $arguments[$key] = $value; 48 | } 49 | } 50 | 51 | return sprintf( 52 | '/%s/_design/%s/_view/%s?%s', 53 | $this->databaseName, 54 | $this->designDocumentName, 55 | $this->viewName, 56 | http_build_query($arguments) 57 | ); 58 | } 59 | 60 | /** 61 | * Find key in view. 62 | * 63 | * @param string|array $val 64 | * 65 | * @return Query 66 | */ 67 | public function setKey($val) 68 | { 69 | $this->params['key'] = $val; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Find keys in the view. 76 | * 77 | * @param array $values 78 | * 79 | * @return Query 80 | */ 81 | public function setKeys(array $values) 82 | { 83 | $this->params['keys'] = $values; 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * Set starting key to query view for. 90 | * 91 | * @param string $val 92 | * 93 | * @return Query 94 | */ 95 | public function setStartKey($val) 96 | { 97 | $this->params['startkey'] = $val; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * Set ending key to query view for. 104 | * 105 | * @param string $val 106 | * 107 | * @return Query 108 | */ 109 | public function setEndKey($val) 110 | { 111 | $this->params['endkey'] = $val; 112 | 113 | return $this; 114 | } 115 | 116 | /** 117 | * Document id to start with. 118 | * 119 | * @param string $val 120 | * 121 | * @return Query 122 | */ 123 | public function setStartKeyDocId($val) 124 | { 125 | $this->params['startkey_docid'] = $val; 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Last document id to include in the output. 132 | * 133 | * @param string $val 134 | * 135 | * @return Query 136 | */ 137 | public function setEndKeyDocId($val) 138 | { 139 | $this->params['endkey_docid'] = $val; 140 | 141 | return $this; 142 | } 143 | 144 | /** 145 | * Limit the number of documents in the output. 146 | * 147 | * @param int $val 148 | * 149 | * @return Query 150 | */ 151 | public function setLimit($val) 152 | { 153 | $this->params['limit'] = $val; 154 | 155 | return $this; 156 | } 157 | 158 | /** 159 | * Skip n number of documents. 160 | * 161 | * @param int $val 162 | * 163 | * @return Query 164 | */ 165 | public function setSkip($val) 166 | { 167 | $this->params['skip'] = $val; 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * If stale=ok is set CouchDB will not refresh the view even if it is stalled. 174 | * 175 | * @param bool $flag 176 | * 177 | * @return Query 178 | */ 179 | public function setStale($flag) 180 | { 181 | if (!is_bool($flag)) { 182 | $this->params['stale'] = $flag; 183 | } elseif ($flag === true) { 184 | $this->params['stale'] = 'ok'; 185 | } else { 186 | unset($this->params['stale']); 187 | } 188 | 189 | return $this; 190 | } 191 | 192 | /** 193 | * reverse the output. 194 | * 195 | * @param bool $flag 196 | * 197 | * @return Query 198 | */ 199 | public function setDescending($flag) 200 | { 201 | $this->params['descending'] = $flag; 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * The group option controls whether the reduce function reduces to a set of distinct keys or to a single result row. 208 | * 209 | * @param bool $flag 210 | * 211 | * @return Query 212 | */ 213 | public function setGroup($flag) 214 | { 215 | $this->params['group'] = $flag; 216 | 217 | return $this; 218 | } 219 | 220 | public function setGroupLevel($level) 221 | { 222 | $this->params['group_level'] = $level; 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * Use the reduce function of the view. It defaults to true, if a reduce function is defined and to false otherwise. 229 | * 230 | * @param bool $flag 231 | * 232 | * @return Query 233 | */ 234 | public function setReduce($flag) 235 | { 236 | $this->params['reduce'] = $flag; 237 | 238 | return $this; 239 | } 240 | 241 | /** 242 | * Controls whether the endkey is included in the result. It defaults to true. 243 | * 244 | * @param bool $flag 245 | * 246 | * @return Query 247 | */ 248 | public function setInclusiveEnd($flag) 249 | { 250 | $this->params['inclusive_end'] = $flag; 251 | 252 | return $this; 253 | } 254 | 255 | /** 256 | * Automatically fetch and include the document which emitted each view entry. 257 | * 258 | * @param bool $flag 259 | * 260 | * @return Query 261 | */ 262 | public function setIncludeDocs($flag) 263 | { 264 | $this->params['include_docs'] = $flag; 265 | 266 | return $this; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/StreamClient.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class StreamClient extends AbstractHTTPClient 19 | { 20 | /** 21 | * Connection pointer for connections, once keep alive is working on the 22 | * CouchDb side. 23 | * 24 | * @var resource 25 | */ 26 | protected $httpFilePointer; 27 | 28 | /** 29 | * Return the connection pointer after setting up the stream connection. 30 | * The returned resource can later be used to read data in chunks. 31 | * 32 | * @param string $method 33 | * @param string $path 34 | * @param string $data 35 | * @param array $headers 36 | * 37 | * @throws HTTPException 38 | * 39 | * @return resource 40 | */ 41 | public function getConnection( 42 | $method, 43 | $path, 44 | $data = null, 45 | array $headers = [] 46 | ) { 47 | $fullPath = $path; 48 | if ($this->options['path']) { 49 | $fullPath = '/'.$this->options['path'].$path; 50 | } 51 | 52 | $this->checkConnection($method, $fullPath, $data, $headers); 53 | 54 | return $this->httpFilePointer; 55 | } 56 | 57 | /** 58 | * Sets up the stream connection. 59 | * 60 | * @param $method 61 | * @param $path 62 | * @param $data 63 | * @param $headers 64 | * 65 | * @throws HTTPException 66 | */ 67 | protected function checkConnection($method, $path, $data, $headers) 68 | { 69 | $basicAuth = ''; 70 | if ($this->options['username']) { 71 | $basicAuth .= "{$this->options['username']}:{$this->options['password']}@"; 72 | } 73 | if ($this->options['headers']) { 74 | $headers = array_merge($this->options['headers'], $headers); 75 | } 76 | if (!isset($headers['Content-Type'])) { 77 | $headers['Content-Type'] = 'application/json'; 78 | } 79 | $stringHeader = ''; 80 | if ($headers != null) { 81 | foreach ($headers as $key => $val) { 82 | $stringHeader .= $key.': '.$val."\r\n"; 83 | } 84 | } 85 | if ($this->httpFilePointer == null) { 86 | // TODO SSL support? 87 | $host = $this->options['host']; 88 | if ($this->options['port'] != 80) { 89 | $host .= ":{$this->options['port']}"; 90 | } 91 | $this->httpFilePointer = @fopen( 92 | 'http://'.$basicAuth.$host.$path, 93 | 'r', 94 | false, 95 | stream_context_create( 96 | [ 97 | 'http' => [ 98 | 'method' => $method, 99 | 'content' => $data, 100 | 'ignore_errors' => true, 101 | 'max_redirects' => 0, 102 | 'user_agent' => 'Doctrine CouchDB ODM $Revision$', 103 | 'timeout' => $this->options['timeout'], 104 | 'header' => $stringHeader, 105 | ], 106 | ] 107 | ) 108 | ); 109 | } 110 | 111 | // Check if connection has been established successfully. 112 | if ($this->httpFilePointer === false) { 113 | $error = error_get_last(); 114 | throw HTTPException::connectionFailure( 115 | $this->options['ip'], 116 | $this->options['port'], 117 | $error['message'], 118 | 0 119 | ); 120 | } 121 | } 122 | 123 | /** 124 | * @param $connection 125 | * 126 | * @return array 127 | */ 128 | public function getStreamHeaders($connection = null) 129 | { 130 | if ($connection == null) { 131 | $connection = $this->httpFilePointer; 132 | } 133 | $headers = []; 134 | if ($connection !== false) { 135 | $metaData = stream_get_meta_data($connection); 136 | // The structure of this array differs depending on PHP compiled with 137 | // --enable-curlwrappers or not. Both cases are normally required. 138 | $rawHeaders = isset($metaData['wrapper_data']['headers']) 139 | ? $metaData['wrapper_data']['headers'] : $metaData['wrapper_data']; 140 | 141 | foreach ($rawHeaders as $lineContent) { 142 | // Extract header values 143 | if (preg_match('(^HTTP/(?P\d+\.\d+)\s+(?P\d+))S', $lineContent, $match)) { 144 | $headers['version'] = $match['version']; 145 | $headers['status'] = (int) $match['status']; 146 | } else { 147 | list($key, $value) = explode(':', $lineContent, 2); 148 | $headers[strtolower($key)] = ltrim($value); 149 | } 150 | } 151 | } 152 | 153 | return $headers; 154 | } 155 | 156 | /** 157 | * Perform a request to the server and return the result. 158 | * 159 | * Perform a request to the server and return the result converted into a 160 | * Response object. If you do not expect a JSON structure, which 161 | * could be converted in such a response object, set the forth parameter to 162 | * true, and you get a response object returned, containing the raw body. 163 | * 164 | * @param string $method 165 | * @param string $path 166 | * @param string $data 167 | * @param bool $raw 168 | * @param array $headers 169 | * 170 | * @throws HTTPException 171 | * 172 | * @return Response 173 | */ 174 | public function request($method, $path, $data = null, $raw = false, array $headers = []) 175 | { 176 | $fullPath = $path; 177 | if ($this->options['path']) { 178 | $fullPath = '/'.$this->options['path'].$path; 179 | } 180 | 181 | $this->checkConnection($method, $fullPath, $data, $headers); 182 | 183 | // Read request body. 184 | $body = ''; 185 | while (!feof($this->httpFilePointer)) { 186 | $body .= fgets($this->httpFilePointer); 187 | } 188 | 189 | $headers = $this->getStreamHeaders(); 190 | 191 | if (empty($headers['status'])) { 192 | throw HTTPException::readFailure( 193 | $this->options['ip'], 194 | $this->options['port'], 195 | 'Received an empty response or not status code', 196 | 0 197 | ); 198 | } 199 | 200 | // Create response object from couch db response. 201 | if ($headers['status'] >= 400) { 202 | return new ErrorResponse($headers['status'], $headers, $body); 203 | } 204 | 205 | return new Response($headers['status'], $headers, $body, $raw); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/Attachment.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class Attachment 23 | { 24 | /** 25 | * Content-Type of the Attachment. 26 | * 27 | * If this is false on putting a new attachment into the database the 28 | * generic "application/octet-stream" type will be used. 29 | * 30 | * @var string 31 | */ 32 | private $contentType = false; 33 | 34 | /** 35 | * Base64 Encoded tring of the Data. 36 | * 37 | * @var string 38 | */ 39 | private $data; 40 | 41 | /** 42 | * @var string 43 | */ 44 | private $binaryData; 45 | 46 | /** 47 | * This attachment only represented as stub, which means the attachment is standalone and not inline. 48 | * 49 | * WARNING: Never change this variable from true to false if you don't provide data. 50 | * CouchDB otherwise quits with an error: {"error":"unknown_error","reason":"function_clause"} 51 | * 52 | * @var bool 53 | */ 54 | private $stub = true; 55 | 56 | /** 57 | * Size of the attachment. 58 | * 59 | * @var int 60 | */ 61 | private $length = 0; 62 | 63 | /** 64 | * Revision Position field of this Attachment. 65 | * 66 | * @var int 67 | */ 68 | private $revpos = 0; 69 | 70 | /** 71 | * @var Client 72 | */ 73 | private $httpClient; 74 | 75 | /** 76 | * @var string 77 | */ 78 | private $path; 79 | 80 | /** 81 | * Get the content-type of this attachment. 82 | * 83 | * @return string 84 | */ 85 | public function getContentType() 86 | { 87 | return $this->contentType; 88 | } 89 | 90 | /** 91 | * Get the length of the base64 encoded representation of this attachment. 92 | * 93 | * @return int 94 | */ 95 | public function getLength() 96 | { 97 | if (!$this->stub && !is_int($this->length)) { 98 | $this->length = strlen($this->data); 99 | } 100 | 101 | return $this->length; 102 | } 103 | 104 | /** 105 | * Get the raw data of this attachment. 106 | * 107 | * @return string 108 | */ 109 | public function getRawData() 110 | { 111 | $this->lazyLoad(); 112 | 113 | return $this->binaryData; 114 | } 115 | 116 | /** 117 | * @return string 118 | */ 119 | public function getBase64EncodedData() 120 | { 121 | $this->lazyLoad(); 122 | 123 | return $this->data; 124 | } 125 | 126 | /** 127 | * Lazy Load Data from CouchDB if necessary. 128 | * 129 | * @return void 130 | */ 131 | private function lazyLoad() 132 | { 133 | if ($this->stub) { 134 | $response = $this->httpClient->request('GET', $this->path, null, true); // raw request 135 | if ($response->status != 200) { 136 | throw HTTPException::fromResponse($this->path, $response); 137 | } 138 | $this->stub = false; 139 | $this->binaryData = $response->body; 140 | $this->data = \base64_encode($this->binaryData); 141 | } 142 | } 143 | 144 | public function isLoaded() 145 | { 146 | return !$this->stub; 147 | } 148 | 149 | /** 150 | * Number of times an attachment was alreaady saved with the document, indicating in which revision it was added. 151 | * 152 | * @return int 153 | */ 154 | public function getRevPos() 155 | { 156 | return $this->revpos; 157 | } 158 | 159 | /** 160 | * Attachments are special in how they need to be persisted depending on stub or not. 161 | * 162 | * TODO: Is this really necessary with all this special logic? Having attahments as special 163 | * case without special code would be really awesome. 164 | * 165 | * @return string 166 | */ 167 | public function toArray() 168 | { 169 | if ($this->stub) { 170 | $json = ['stub' => true]; 171 | } else { 172 | $json = ['data' => $this->getBase64EncodedData()]; 173 | if ($this->contentType) { 174 | $json['content_type'] = $this->contentType; 175 | } 176 | } 177 | 178 | return $json; 179 | } 180 | 181 | /** 182 | * @param string $binaryData 183 | * @param string $base64Data 184 | * @param string $contentType 185 | * @param int $length 186 | * @param int $revPos 187 | * @param Client $httpClient 188 | * @param string $path 189 | */ 190 | final private function __construct($binaryData = null, $base64Data = null, $contentType = false, $length = false, $revPos = false, $httpClient = null, $path = null) 191 | { 192 | if ($binaryData || $base64Data) { 193 | $this->binaryData = $binaryData; 194 | $this->data = $base64Data; 195 | $this->stub = false; 196 | } else { 197 | $this->stub = true; 198 | } 199 | $this->contentType = $contentType; 200 | $this->length = $length; 201 | $this->revpos = $revPos; 202 | $this->httpClient = $httpClient; 203 | $this->path = $path; 204 | } 205 | 206 | /** 207 | * Create an Attachment from a string or resource of binary data. 208 | * 209 | * WARNING: Changes to the file handle after calling this method will *NOT* be recognized anymore. 210 | * 211 | * @param string|resource $data 212 | * @param string $contentType 213 | * 214 | * @return Attachment 215 | */ 216 | public static function createFromBinaryData($data, $contentType = false) 217 | { 218 | if (\is_resource($data)) { 219 | $data = \stream_get_contents($data); 220 | } 221 | 222 | return new self($data, \base64_encode($data), $contentType); 223 | } 224 | 225 | /** 226 | * Create an attachment from base64 data. 227 | * 228 | * @param string $data 229 | * @param string $contentType 230 | * @param int $revpos 231 | * 232 | * @return Attachment 233 | */ 234 | public static function createFromBase64Data($data, $contentType = false, $revpos = false) 235 | { 236 | return new self(\base64_decode($data), $data, $contentType, false, $revpos); 237 | } 238 | 239 | /** 240 | * Create a stub attachment that has lazy loading capabilities. 241 | * 242 | * @param string $contentType 243 | * @param int $length 244 | * @param int $revPos 245 | * @param Client $httpClient 246 | * @param string $path 247 | * 248 | * @return Attachment 249 | */ 250 | public static function createStub($contentType, $length, $revPos, Client $httpClient, $path) 251 | { 252 | return new self(null, null, $contentType, $length, $revPos, $httpClient, $path); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/Functional/MangoTest.php: -------------------------------------------------------------------------------- 1 | createCouchDBClient(); 12 | $client->deleteDatabase($this->getTestDatabase()); 13 | sleep(0.5); 14 | $client->createDatabase($this->getTestDatabase()); 15 | } 16 | 17 | public function testFind() 18 | { 19 | $client = $this->createMangoClient(); 20 | 21 | $string = file_get_contents(__DIR__.'/../../datasets/shows.json'); 22 | $shows = json_decode($string, true); 23 | 24 | $updater = $client->createBulkUpdater(); 25 | $updater->updateDocuments($shows); 26 | $response = $updater->execute(); 27 | 28 | foreach ($response->body as $key=>$row) { 29 | $shows[$key] = ['_id'=>$row['id'], '_rev'=>$row['rev']] + $shows[$key]; 30 | } 31 | 32 | //Everything 33 | $query = new MangoQuery(); 34 | $response = $client->find($query->limit(999)); 35 | 36 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 37 | $this->assertObjectHasAttribute('body', $response); 38 | $this->assertArrayHasKey('docs', $response->body); 39 | 40 | $this->assertEquals($shows, $response->body['docs']); 41 | 42 | //Query by a field with no index 43 | $response = $client->find(new MangoQuery(['name'=>['$eq'=>'Under the Dome']])); 44 | 45 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 46 | $this->assertObjectHasAttribute('body', $response); 47 | $this->assertArrayHasKey('docs', $response->body); 48 | //No index found warning 49 | $this->assertArrayHasKey('warning', $response->body); 50 | $this->assertEquals([$shows[0]], $response->body['docs']); 51 | 52 | 53 | //Nothing 54 | $response = $client->find(new MangoQuery(['_id'=>['$eq'=>null]])); 55 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 56 | $this->assertObjectHasAttribute('body', $response); 57 | $this->assertArrayHasKey('docs', $response->body); 58 | 59 | $this->assertEquals([], $response->body['docs']); 60 | 61 | //Selector Basics 62 | $response = $client->find(new MangoQuery(['name'=>'Person of Interest'])); 63 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 64 | $this->assertObjectHasAttribute('body', $response); 65 | $this->assertArrayHasKey('docs', $response->body); 66 | $this->assertEquals([$shows[1]], $response->body['docs']); 67 | 68 | //Selector with 2 fields 69 | $response = $client->find(new MangoQuery(['name'=>'Person of Interest', 'language'=>'English'])); 70 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 71 | $this->assertObjectHasAttribute('body', $response); 72 | $this->assertArrayHasKey('docs', $response->body); 73 | $this->assertEquals([$shows[1]], $response->body['docs']); 74 | 75 | //Condition Operators 76 | $response = $client->find(new MangoQuery(['runtime'=>['$gt'=>60]])); 77 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 78 | $this->assertObjectHasAttribute('body', $response); 79 | $this->assertArrayHasKey('docs', $response->body); 80 | $this->assertEquals(2, count($response->body['docs'])); 81 | 82 | //Subfields 83 | $response = $client->find(new MangoQuery(['rating'=>['average'=>8]])); 84 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 85 | $this->assertObjectHasAttribute('body', $response); 86 | $this->assertArrayHasKey('docs', $response->body); 87 | $this->assertEquals(9, count($response->body['docs'])); 88 | 89 | //Subfield with dot notation 90 | $response = $client->find(new MangoQuery(['rating.average'=>8])); 91 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 92 | $this->assertObjectHasAttribute('body', $response); 93 | $this->assertArrayHasKey('docs', $response->body); 94 | $this->assertEquals(9, count($response->body['docs'])); 95 | 96 | //Explicit $and 97 | $response = $client->find(new MangoQuery( 98 | ['$and'=> [ 99 | [ 100 | 'name'=> [ 101 | '$eq'=> 'Under the Dome', 102 | ], 103 | 'genres'=> [ 104 | '$in'=> ['Drama'], 105 | ], 106 | ], 107 | ], 108 | ])); 109 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 110 | $this->assertObjectHasAttribute('body', $response); 111 | $this->assertArrayHasKey('docs', $response->body); 112 | $this->assertEquals(1, count($response->body['docs'])); 113 | $this->assertEquals([$shows[0]], $response->body['docs']); 114 | 115 | //$or operator 116 | $response = $client->find(new MangoQuery( 117 | [ 118 | 'runtime'=> 60, 119 | '$or' => [ 120 | [ 121 | 'name'=> 'Under the Dome', 122 | ], 123 | [ 124 | 'name'=> 'Person of Interest', 125 | ], 126 | ], 127 | ])); 128 | 129 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 130 | $this->assertObjectHasAttribute('body', $response); 131 | $this->assertArrayHasKey('docs', $response->body); 132 | $this->assertEquals(2, count($response->body['docs'])); 133 | $this->assertEquals([$shows[0], $shows[1]], $response->body['docs']); 134 | 135 | //repeated key 136 | $response = $client->find(new MangoQuery( 137 | ['$and'=> [ 138 | ['rating.average'=>['$gte'=>9]], 139 | ['rating.average'=> ['$lte'=>10]], 140 | ], 141 | ])); 142 | 143 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 144 | $this->assertObjectHasAttribute('body', $response); 145 | $this->assertArrayHasKey('docs', $response->body); 146 | $this->assertEquals(17, count($response->body['docs'])); 147 | 148 | //Limits and skips 149 | 150 | //Get first 151 | $response = $client->find((new MangoQuery())->limit(1)); 152 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 153 | $this->assertObjectHasAttribute('body', $response); 154 | $this->assertArrayHasKey('docs', $response->body); 155 | $this->assertEquals(1, count($response->body['docs'])); 156 | $this->assertEquals([$shows[0]], $response->body['docs']); 157 | 158 | //Get second 159 | $response = $client->find((new MangoQuery())->limit(1)->skip(1)); 160 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 161 | $this->assertObjectHasAttribute('body', $response); 162 | $this->assertArrayHasKey('docs', $response->body); 163 | $this->assertEquals(1, count($response->body['docs'])); 164 | $this->assertEquals([$shows[1]], $response->body['docs']); 165 | 166 | //Select fields 167 | $expected = [ 168 | [ 169 | 'id' => $shows[1]['id'], 170 | 'name'=> $shows[1]['name'], 171 | ], 172 | ]; 173 | 174 | $query = new MangoQuery(); 175 | $query->select(['id', 'name'])->skip(1)->limit(1); 176 | 177 | $response = $client->find($query); 178 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 179 | $this->assertObjectHasAttribute('body', $response); 180 | $this->assertArrayHasKey('docs', $response->body); 181 | $this->assertEquals(1, count($response->body['docs'])); 182 | $this->assertEquals($expected, $response->body['docs']); 183 | } 184 | 185 | public function testMangoIndexAndSort() 186 | { 187 | //Fill database 188 | $client = $this->createMangoClient(); 189 | $string = file_get_contents(__DIR__.'/../../datasets/shows.json'); 190 | $shows = json_decode($string, true); 191 | $updater = $client->createBulkUpdater(); 192 | $updater->updateDocuments($shows); 193 | $response = $updater->execute(); 194 | 195 | //create index 196 | $fields = [['name'=>'desc']]; 197 | $response = $client->createMangoIndex($fields, 'index-test', 'name-desc'); 198 | 199 | $this->assertObjectHasAttribute('body', $response); 200 | $this->assertEquals('created', $response->body['result']); 201 | $this->assertEquals('_design/index-test', $response->body['id']); 202 | $this->assertEquals('name-desc', $response->body['name']); 203 | 204 | $response = $client->find(new MangoQuery(['name'=>['$eq'=>'Under the Dome']])); 205 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 206 | $this->assertObjectHasAttribute('body', $response); 207 | $this->assertArrayHasKey('docs', $response->body); 208 | $this->assertArrayNotHasKey('warning', $response->body); 209 | 210 | //Test sort 211 | $query = new MangoQuery(['name'=>['$gt'=>null]]); 212 | $query->sort([['name'=>'desc']]); 213 | $response = $client->find($query); 214 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 215 | $this->assertObjectHasAttribute('body', $response); 216 | $this->assertArrayHasKey('docs', $response->body); 217 | $this->assertArrayNotHasKey('warning', $response->body); 218 | 219 | $this->assertEquals('Z Nation', $response->body['docs'][0]['name']); 220 | 221 | $deleted = $client->deleteMangoIndex('index-test', 'name-desc'); 222 | $this->assertTrue($deleted); 223 | 224 | //create subdocument index 225 | $fields = [['rating.average'=>'desc'], ['name'=>'desc']]; 226 | $response = $client->createMangoIndex($fields, 'index-test', 'rating.average-desc'); 227 | $query = new MangoQuery(['rating.average'=>['$gt'=>null]]); 228 | $query->sort($fields); 229 | 230 | $response = $client->find($query); 231 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 232 | $this->assertObjectHasAttribute('body', $response); 233 | $this->assertArrayHasKey('docs', $response->body); 234 | $this->assertArrayNotHasKey('warning', $response->body); 235 | $this->assertEquals('The Wire', $response->body['docs'][0]['name']); 236 | 237 | //Create another index in the same document 238 | $fields = [['type'=>'asc'], ['name'=>'asc']]; 239 | $response = $client->createMangoIndex($fields, 'index-test', 'type-asc&name-asc'); 240 | $this->assertObjectHasAttribute('body', $response); 241 | $this->assertEquals('created', $response->body['result']); 242 | $this->assertEquals('_design/index-test', $response->body['id']); 243 | $this->assertEquals('type-asc&name-asc', $response->body['name']); 244 | $query = new MangoQuery(['type'=>['$gt'=>null]]); 245 | $query->sort($fields); 246 | $response = $client->find($query); 247 | $this->assertEquals('American Dad!', $response->body['docs'][0]['name']); 248 | 249 | //Find for impacts 250 | $query = new MangoQuery(['rating.average'=>['$gt'=>null]]); 251 | $query->sort([['rating.average'=>'desc'], ['name'=>'desc']]); 252 | $response = $client->find($query); 253 | $this->assertObjectHasAttribute('body', $response); 254 | $this->assertArrayHasKey('docs', $response->body); 255 | $this->assertArrayNotHasKey('warning', $response->body); 256 | $this->assertEquals('The Wire', $response->body['docs'][0]['name']); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/SocketClient.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class SocketClient extends AbstractHTTPClient 18 | { 19 | /** 20 | * Connection pointer for connections, once keep alive is working on the 21 | * CouchDb side. 22 | * 23 | * @var resource 24 | */ 25 | protected $connection; 26 | 27 | /** 28 | * Return the socket after setting up the connection to server and writing 29 | * the headers. The returned resource can later be used to read and 30 | * write data in chunks. These should be done without much delay as the 31 | * connection might get closed. 32 | * 33 | * @throws HTTPException 34 | * 35 | * @return resource 36 | */ 37 | public function getConnection( 38 | $method, 39 | $path, 40 | $data = null, 41 | array $headers = [] 42 | ) { 43 | $fullPath = $path; 44 | if ($this->options['path']) { 45 | $fullPath = '/'.$this->options['path'].$path; 46 | } 47 | 48 | $this->checkConnection(); 49 | $stringHeader = $this->buildRequest($method, $fullPath, $data, $headers); 50 | // Send the build request to the server 51 | if (fwrite($this->connection, $stringHeader) === false) { 52 | // Reestablish which seems to have been aborted 53 | // 54 | // The recursion in this method might be problematic if the 55 | // connection establishing mechanism does not correctly throw an 56 | // exception on failure. 57 | $this->connection = null; 58 | 59 | return $this->getConnection($method, $path, $data, $headers); 60 | } 61 | 62 | return $this->connection; 63 | } 64 | 65 | /** 66 | * Check for server connection. 67 | * 68 | * Checks if the connection already has been established, or tries to 69 | * establish the connection, if not done yet. 70 | * 71 | * @throws HTTPException 72 | * 73 | * @return void 74 | */ 75 | protected function checkConnection() 76 | { 77 | // Setting Connection scheme according ssl support 78 | if ($this->options['ssl']) { 79 | if (!extension_loaded('openssl')) { 80 | // no openssl extension loaded. 81 | // This is a bit hackisch... 82 | $this->connection = null; 83 | 84 | throw HTTPException::connectionFailure( 85 | $this->options['ip'], 86 | $this->options['port'], 87 | 'ssl activated without openssl extension loaded', 88 | 0 89 | ); 90 | } 91 | 92 | $host = 'ssl://'.$this->options['host']; 93 | } else { 94 | $host = $this->options['ip']; 95 | } 96 | 97 | // If the connection could not be established, fsockopen sadly does not 98 | // only return false (as documented), but also always issues a warning. 99 | if (($this->connection === null) && 100 | (($this->connection = @fsockopen($host, $this->options['port'], $errno, $errstr, $this->options['timeout'])) === false)) { 101 | // This is a bit hackisch... 102 | $this->connection = null; 103 | throw HTTPException::connectionFailure( 104 | $this->options['ip'], 105 | $this->options['port'], 106 | $errstr, 107 | $errno 108 | ); 109 | } 110 | } 111 | 112 | /** 113 | * Build a HTTP 1.1 request. 114 | * 115 | * Build the HTTP 1.1 request headers from the given input. 116 | * 117 | * @param string $method 118 | * @param string $path 119 | * @param string $data 120 | * @param array $headers 121 | * 122 | * @return string 123 | */ 124 | protected function buildRequest( 125 | $method, 126 | $path, 127 | $data = null, 128 | array $headers = [] 129 | ) { 130 | // Create basic request headers 131 | $host = "Host: {$this->options['host']}"; 132 | if ($this->options['port'] != 80) { 133 | $host .= ":{$this->options['port']}"; 134 | } 135 | $request = "$method $path HTTP/1.1\r\n$host\r\n"; 136 | 137 | // Add basic auth if set 138 | if ($this->options['username']) { 139 | $request .= sprintf("Authorization: Basic %s\r\n", 140 | base64_encode($this->options['username'].':'.$this->options['password']) 141 | ); 142 | } 143 | 144 | // Set keep-alive header, which helps to keep to connection 145 | // initialization costs low, especially when the database server is not 146 | // available in the locale net. 147 | $request .= 'Connection: '.($this->options['keep-alive'] ? 'Keep-Alive' : 'Close')."\r\n"; 148 | 149 | if ($this->options['headers']) { 150 | $headers = array_merge($this->options['headers'], $headers); 151 | } 152 | if (!isset($headers['Content-Type'])) { 153 | $headers['Content-Type'] = 'application/json'; 154 | } 155 | foreach ($headers as $key => $value) { 156 | if (is_bool($value) === true) { 157 | $value = ($value) ? 'true' : 'false'; 158 | } 159 | $request .= $key.': '.$value."\r\n"; 160 | } 161 | 162 | // Also add headers and request body if data should be sent to the 163 | // server. Otherwise just add the closing mark for the header section 164 | // of the request. 165 | if ($data !== null) { 166 | $request .= 'Content-Length: '.strlen($data)."\r\n\r\n"; 167 | $request .= $data; 168 | } else { 169 | $request .= "\r\n"; 170 | } 171 | 172 | return $request; 173 | } 174 | 175 | /** 176 | * Perform a request to the server and return the result. 177 | * 178 | * Perform a request to the server and return the result converted into a 179 | * Response object. If you do not expect a JSON structure, which 180 | * could be converted in such a response object, set the forth parameter to 181 | * true, and you get a response object returned, containing the raw body. 182 | * 183 | * @param string $method 184 | * @param string $path 185 | * @param string $data 186 | * @param bool $raw 187 | * @param array $headers 188 | * 189 | * @return Response 190 | */ 191 | public function request($method, $path, $data = null, $raw = false, array $headers = []) 192 | { 193 | $fullPath = $path; 194 | if ($this->options['path']) { 195 | $fullPath = '/'.$this->options['path'].$path; 196 | } 197 | 198 | // Try establishing the connection to the server 199 | $this->checkConnection(); 200 | 201 | // Send the build request to the server 202 | if (fwrite($this->connection, $request = $this->buildRequest($method, $fullPath, $data, $headers)) === false) { 203 | // Reestablish which seems to have been aborted 204 | // 205 | // The recursion in this method might be problematic if the 206 | // connection establishing mechanism does not correctly throw an 207 | // exception on failure. 208 | $this->connection = null; 209 | 210 | return $this->request($method, $path, $data, $raw, $headers); 211 | } 212 | 213 | // Read server response headers 214 | $rawHeaders = ''; 215 | $headers = [ 216 | 'connection' => ($this->options['keep-alive'] ? 'Keep-Alive' : 'Close'), 217 | ]; 218 | 219 | // Remove leading newlines, should not occur at all, actually. 220 | while ((($line = fgets($this->connection)) !== false) && 221 | (($lineContent = rtrim($line)) === '')); 222 | 223 | // Throw exception, if connection has been aborted by the server, and 224 | // leave handling to the user for now. 225 | if ($line === false) { 226 | // Reestablish which seems to have been aborted 227 | // 228 | // The recursion in this method might be problematic if the 229 | // connection establishing mechanism does not correctly throw an 230 | // exception on failure. 231 | // 232 | // An aborted connection seems to happen here on long running 233 | // requests, which cause a connection timeout at server side. 234 | $this->connection = null; 235 | 236 | return $this->request($method, $path, $data, $raw, $headers); 237 | } 238 | 239 | do { 240 | // Also store raw headers for later logging 241 | $rawHeaders .= $lineContent."\n"; 242 | 243 | // Extract header values 244 | if (preg_match('(^HTTP/(?P\d+\.\d+)\s+(?P\d+))S', $lineContent, $match)) { 245 | $headers['version'] = $match['version']; 246 | $headers['status'] = (int) $match['status']; 247 | } else { 248 | list($key, $value) = explode(':', $lineContent, 2); 249 | $headers[strtolower($key)] = ltrim($value); 250 | } 251 | } while ((($line = fgets($this->connection)) !== false) && 252 | (($lineContent = rtrim($line)) !== '')); 253 | 254 | $body = ''; 255 | // Read response body for NON-HEAD requests 256 | if(strtoupper($method) !== 'HEAD'){ 257 | if (!isset($headers['transfer-encoding']) || 258 | ($headers['transfer-encoding'] !== 'chunked')) { 259 | // HTTP 1.1 supports chunked transfer encoding, if the according 260 | // header is not set, just read the specified amount of bytes. 261 | $bytesToRead = (int) (isset($headers['content-length']) ? $headers['content-length'] : 0); 262 | 263 | // Read body only as specified by chunk sizes, everything else 264 | // are just footnotes, which are not relevant for us. 265 | while ($bytesToRead > 0) { 266 | $body .= $read = fgets($this->connection, $bytesToRead + 1); 267 | $bytesToRead -= strlen($read); 268 | } 269 | } else { 270 | // When transfer-encoding=chunked has been specified in the 271 | // response headers, read all chunks and sum them up to the body, 272 | // until the server has finished. Ignore all additional HTTP 273 | // options after that. 274 | do { 275 | $line = rtrim(fgets($this->connection)); 276 | 277 | // Get bytes to read, with option appending comment 278 | if (preg_match('(^([0-9a-f]+)(?:;.*)?$)', $line, $match)) { 279 | $bytesToRead = hexdec($match[1]); 280 | 281 | // Read body only as specified by chunk sizes, everything else 282 | // are just footnotes, which are not relevant for us. 283 | $bytesLeft = $bytesToRead; 284 | while ($bytesLeft > 0) { 285 | $body .= $read = fread($this->connection, $bytesLeft + 2); 286 | $bytesLeft -= strlen($read); 287 | } 288 | } 289 | } while ($bytesToRead > 0); 290 | 291 | // Chop off \r\n from the end. 292 | $body = substr($body, 0, -2); 293 | } 294 | } 295 | 296 | // Reset the connection if the server asks for it. 297 | if ($headers['connection'] !== 'Keep-Alive') { 298 | fclose($this->connection); 299 | $this->connection = null; 300 | } 301 | 302 | // Handle some response state as special cases 303 | switch ($headers['status']) { 304 | case 301: 305 | case 302: 306 | case 303: 307 | case 307: 308 | $path = parse_url($headers['location'], PHP_URL_PATH); 309 | 310 | return $this->request($method, $path, $data, $raw, $headers); 311 | } 312 | 313 | // Create response object from couch db response 314 | if ($headers['status'] >= 400) { 315 | return new ErrorResponse($headers['status'], $headers, $body); 316 | } 317 | 318 | return new Response($headers['status'], $headers, $body, $raw); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/Functional/HTTP/MultipartParserAndSenderTest.php: -------------------------------------------------------------------------------- 1 | streamClientMock = $this->getMockBuilder( 26 | 'Doctrine\CouchDB\HTTP\StreamClient' 27 | )->disableOriginalConstructor()->getMock(); 28 | 29 | $this->parserAndSender = new MultipartParserAndSender( 30 | $this->getHttpClient(), 31 | $this->getHttpClient() 32 | ); 33 | // Set the protected $sourceClient of parserAndSender to the 34 | // streamClientMock. 35 | $reflector = new \ReflectionProperty( 36 | 'Doctrine\CouchDB\HTTP\MultipartParserAndSender', 37 | 'sourceClient' 38 | ); 39 | $reflector->setAccessible(true); 40 | $reflector->setValue($this->parserAndSender, $this->streamClientMock); 41 | 42 | // Params for the request. 43 | $this->sourceParams = ['revs' => true, 'latest' => true]; 44 | $this->sourceMethod = 'GET'; 45 | $this->docId = 'multipartTestDoc'; 46 | $this->sourcePath = '/'.$this->getTestDatabase().'/'.$this->docId; 47 | $this->targetPath = '/'.$this->getTestDatabase().'_multipart_copy' 48 | .'/'.$this->docId.'?new_edits=false'; 49 | $this->sourceHeaders = ['Accept' => 'multipart/mixed']; 50 | } 51 | 52 | public function tearDown() 53 | { 54 | parent::tearDown(); 55 | $this->createCouchDBClient()->deleteDatabase($this->getTestDatabase().'_multipart_copy'); 56 | } 57 | 58 | public function testRequestThrowsHTTPExceptionOnEmptyStatus() 59 | { 60 | $this->setExpectedException( 61 | '\Doctrine\CouchDB\HTTP\HTTPException', 62 | sprintf( 63 | "Could read from server at %s:%d: '%d: %s'", 64 | '127.0.0.1', 65 | '5984', 66 | 0, 67 | 'Received an empty response or not status code' 68 | ) 69 | 70 | ); 71 | // Return header without status code. 72 | $this->streamClientMock->expects($this->once()) 73 | ->method('getStreamHeaders') 74 | ->willReturn([]); 75 | 76 | $this->streamClientMock->expects($this->exactly(2)) 77 | ->method('getOptions') 78 | ->will($this->onConsecutiveCalls( 79 | ['ip' => '127.0.0.1'], 80 | ['port' => '5984'] 81 | )); 82 | 83 | $this->parserAndSender->request( 84 | $this->sourceMethod, 85 | $this->sourcePath, 86 | $this->targetPath, 87 | null, 88 | $this->sourceHeaders 89 | ); 90 | } 91 | 92 | public function testRequestReturnsErrorResponseOnWrongStatusCode() 93 | { 94 | // Return header without status code > 400. 95 | $this->streamClientMock->expects($this->once()) 96 | ->method('getStreamHeaders') 97 | ->willReturn(['status' => 404]); 98 | 99 | $string = 'This is the sample body of the response from the source.\n 100 | It has two lines.'; 101 | $stream = fopen('data://text/plain,'.$string, 'r'); 102 | $this->streamClientMock->expects($this->once()) 103 | ->method('getConnection') 104 | ->willReturn($stream); 105 | 106 | $response = $this->parserAndSender->request( 107 | $this->sourceMethod, 108 | $this->sourcePath, 109 | $this->targetPath, 110 | null, 111 | $this->sourceHeaders 112 | ); 113 | 114 | $this->AssertEquals( 115 | new ErrorResponse( 116 | '404', 117 | ['status' => 404], 118 | $string 119 | ), 120 | $response 121 | ); 122 | } 123 | 124 | /** 125 | * @expectedException \UnexpectedValueException 126 | * @expectedExceptionMessage This value is not supported. 127 | */ 128 | public function testRequestThrowsExceptionOnUnsupportedContentType() 129 | { 130 | // Return header with status code as 200. 131 | $this->streamClientMock->expects($this->once()) 132 | ->method('getStreamHeaders') 133 | ->willReturn(['status' => 200]); 134 | $string = <<<'EOT' 135 | --7b1596fc4940bc1be725ad67f11ec1c4 136 | Content-Type: HTML 137 | EOT; 138 | $stream = fopen('data://text/plain,'.$string, 'r'); 139 | $this->streamClientMock->expects($this->once()) 140 | ->method('getConnection') 141 | ->willReturn($stream); 142 | 143 | $response = $this->parserAndSender->request( 144 | $this->sourceMethod, 145 | $this->sourcePath, 146 | $this->targetPath, 147 | null, 148 | $this->sourceHeaders 149 | ); 150 | } 151 | 152 | /** 153 | * @expectedException \Exception 154 | * @expectedExceptionMessage Unknown parameter with Content-Type. 155 | */ 156 | public function testRequestThrowsExceptionOnUnknownParamWithContentType() 157 | { 158 | // Return header with status code as 200. 159 | $this->streamClientMock->expects($this->once()) 160 | ->method('getStreamHeaders') 161 | ->willReturn(['status' => 200]); 162 | $string = <<<'EOT' 163 | --7b1596fc4940bc1be725ad67f11ec1c4 164 | Content-Type: application/json; unknownBlahBlah="true" 165 | EOT; 166 | $stream = fopen('data://text/plain,'.$string, 'r'); 167 | $this->streamClientMock->expects($this->once()) 168 | ->method('getConnection') 169 | ->willReturn($stream); 170 | 171 | $response = $this->parserAndSender->request( 172 | $this->sourceMethod, 173 | $this->sourcePath, 174 | $this->targetPath, 175 | null, 176 | $this->sourceHeaders 177 | ); 178 | } 179 | 180 | public function testRequestSuccessWithoutAttachment() 181 | { 182 | // Return header with status code as 200. 183 | $this->streamClientMock->expects($this->once()) 184 | ->method('getStreamHeaders') 185 | ->willReturn(['status' => 200]); 186 | $docs = [ 187 | '{"_id": "'.$this->docId.'","_rev": "1-abc","foo":"bar"}', 188 | '{"_id": "'.$this->docId.'","_rev": "1-abcd","foo":"baz"}', 189 | '{"_id": "'.$this->docId.'","_rev": "1-abcde","foo":"baz"}', 190 | ]; 191 | $string = <<streamClientMock->expects($this->once()) 212 | ->method('getConnection') 213 | ->willReturn($stream); 214 | 215 | $response = $this->parserAndSender->request( 216 | $this->sourceMethod, 217 | $this->sourcePath, 218 | $this->targetPath, 219 | null, 220 | $this->sourceHeaders 221 | ); 222 | // The returned response should have the JSON docs. The missing 223 | // revision at the source will be skipped. 224 | $this->AssertEquals(2, count($response)); 225 | $this->AssertEquals(3, count($response[0])); 226 | $this->AssertEquals($docs[0], $response[0][0]); 227 | $this->AssertEquals($docs[1], $response[0][1]); 228 | $this->AssertEquals($docs[2], $response[0][2]); 229 | } 230 | 231 | public function testRequestSuccessWithAttachments() 232 | { 233 | $client = new CouchDBClient( 234 | $this->getHttpClient(), 235 | $this->getTestDatabase() 236 | ); 237 | // Recreate DB 238 | $client->deleteDatabase($this->getTestDatabase()); 239 | $client->createDatabase($this->getTestDatabase()); 240 | 241 | // Doc id. 242 | $id = $this->docId; 243 | // Document with attachments. 244 | $docWithAttachment = [ 245 | '_id' => $id, 246 | '_rev' => '1-abc', 247 | '_attachments' => [ 248 | 'foo.txt' => [ 249 | 'content_type' => 'text/plain', 250 | 'data' => 'VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=', 251 | ], 252 | 'bar.txt' => [ 253 | 'content_type' => 'text/plain', 254 | 'data' => 'VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=', 255 | ], 256 | ], 257 | ]; 258 | // Doc without any attachment. The id of both the docs is same. 259 | // So we will get two leaf revisions. 260 | $doc = ['_id' => $id, 'foo' => 'bar', '_rev' => '1-bcd']; 261 | 262 | // Add the documents to the test db using Bulk API. 263 | $updater = $client->createBulkUpdater(); 264 | $updater->updateDocument($docWithAttachment); 265 | $updater->updateDocument($doc); 266 | // Set newedits to false to use the supplied _rev instead of assigning 267 | // new ones. 268 | $updater->setNewEdits(false); 269 | $response = $updater->execute(); 270 | 271 | // Create the copy database and a copyClient to interact with it. 272 | $copyDb = $this->getTestDatabase().'_multipart_copy'; 273 | $client->createDatabase($copyDb); 274 | $copyClient = new CouchDBClient($client->getHttpClient(), $copyDb); 275 | 276 | // Missing revisions in the $copyDb. 277 | $missingRevs = ['1-abc', '1-bcd']; 278 | $this->sourceParams['open_revs'] = json_encode($missingRevs); 279 | $query = http_build_query($this->sourceParams); 280 | $this->sourcePath .= '?'.$query; 281 | 282 | // Get the multipart data stream from real CouchDB instance. 283 | $stream = (new StreamClient())->getConnection( 284 | $this->sourceMethod, 285 | $this->sourcePath, 286 | null, 287 | $this->sourceHeaders 288 | ); 289 | 290 | // Set the return values for the mocked StreamClient. 291 | $this->streamClientMock->expects($this->once()) 292 | ->method('getConnection') 293 | ->willReturn($stream); 294 | // Return header with status code as 200. 295 | $this->streamClientMock->expects($this->once()) 296 | ->method('getStreamHeaders') 297 | ->willReturn(['status' => 200]); 298 | 299 | // Transfer the missing revisions from the source to the target. 300 | list($docStack, $responses) = $this->parserAndSender->request( 301 | $this->sourceMethod, 302 | $this->sourcePath, 303 | $this->targetPath, 304 | null, 305 | $this->sourceHeaders 306 | 307 | ); 308 | // $docStack should contain the doc that didn't have the attachment. 309 | $this->assertEquals(1, count($docStack)); 310 | $this->assertEquals($doc, json_decode($docStack[0], true)); 311 | 312 | // The doc with attachment should have been copied to the copyDb. 313 | $this->assertEquals(1, count($responses)); 314 | $this->assertArrayHasKey('ok', $responses[0]); 315 | $this->assertEquals(true, $responses[0]['ok']); 316 | // Clean up. 317 | $client->deleteDatabase($this->getTestDatabase()); 318 | $client->createDatabase($this->getTestDatabase()); 319 | $client->deleteDatabase($copyDb); 320 | } 321 | 322 | /** 323 | * Test multipart request with body size in the request body. 324 | */ 325 | public function testMultipartRequestWithSize() 326 | { 327 | $this->streamClientMock->expects($this->once()) 328 | ->method('getStreamHeaders') 329 | ->willReturn(['status' => 200]); 330 | $docs = [ 331 | '{"_id": "'.$this->docId.'","_rev": "1-abc","foo":"bar"}', 332 | '{"_id": "'.$this->docId.'","_rev": "1-abcd","foo":"baz"}', 333 | '{"_id": "'.$this->docId.'","_rev": "1-abcde","foo":"baz"}', 334 | ]; 335 | $string = <<streamClientMock->expects($this->once()) 359 | ->method('getConnection') 360 | ->willReturn($stream); 361 | 362 | $response = $this->parserAndSender->request( 363 | $this->sourceMethod, 364 | $this->sourcePath, 365 | $this->targetPath, 366 | null, 367 | $this->sourceHeaders 368 | ); 369 | // The returned response should have the JSON docs. 370 | $this->AssertEquals(2, count($response)); 371 | $this->AssertEquals(3, count($response[0])); 372 | $this->AssertEquals($docs[0], $response[0][0]); 373 | $this->AssertEquals($docs[1], $response[0][1]); 374 | $this->AssertEquals($docs[2], $response[0][2]); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/HTTP/MultipartParserAndSender.php: -------------------------------------------------------------------------------- 1 | getOptions(); 42 | $this->sourceClient = new StreamClient( 43 | $sourceOptions['host'], 44 | $sourceOptions['port'], 45 | $sourceOptions['username'], 46 | $sourceOptions['password'], 47 | $sourceOptions['ip'], 48 | $sourceOptions['ssl'], 49 | $sourceOptions['path'], 50 | $sourceOptions['timeout'], 51 | $sourceOptions['headers'] 52 | ); 53 | 54 | $targetOptions = $target->getOptions(); 55 | $this->targetClient = new SocketClient( 56 | $targetOptions['host'], 57 | $targetOptions['port'], 58 | $targetOptions['username'], 59 | $targetOptions['password'], 60 | $targetOptions['ip'], 61 | $targetOptions['ssl'], 62 | $targetOptions['path'], 63 | $targetOptions['timeout'], 64 | $sourceOptions['headers'] 65 | ); 66 | } 67 | 68 | /** 69 | * Perform request to the source, parse the multipart response, 70 | * stream the documents with attachments to the target and return 71 | * the responses along with docs that did not have any attachments. 72 | * 73 | * @param string $sourceMethod 74 | * @param string $sourcePath 75 | * @param string $targetPath 76 | * @param string $sourceData 77 | * @param array $sourceHeaders 78 | * 79 | * @throws HTTPException 80 | * @throws \Exception 81 | * 82 | * @return array|ErrorResponse|string 83 | */ 84 | public function request( 85 | $sourceMethod, 86 | $sourcePath, 87 | $targetPath, 88 | $sourceData = null, 89 | array $sourceHeaders = [] 90 | ) { 91 | $this->sourceConnection = $this->sourceClient->getConnection( 92 | $sourceMethod, 93 | $sourcePath, 94 | $sourceData, 95 | $sourceHeaders 96 | ); 97 | $sourceResponseHeaders = $this->sourceClient->getStreamHeaders(); 98 | $body = ''; 99 | 100 | if (empty($sourceResponseHeaders['status'])) { 101 | try { 102 | // Close the connection resource. 103 | fclose($this->sourceConnection); 104 | } catch (\Exception $e) { 105 | } 106 | throw HTTPException::readFailure( 107 | $this->sourceClient->getOptions()['ip'], 108 | $this->sourceClient->getOptions()['port'], 109 | 'Received an empty response or not status code', 110 | 0 111 | ); 112 | } elseif ($sourceResponseHeaders['status'] != 200) { 113 | while (!feof($this->sourceConnection)) { 114 | $body .= fgets($this->sourceConnection); 115 | } 116 | try { 117 | fclose($this->sourceConnection); 118 | } catch (\Exception $e) { 119 | } 120 | 121 | return new ErrorResponse( 122 | $sourceResponseHeaders['status'], 123 | $sourceResponseHeaders, 124 | $body 125 | ); 126 | } else { 127 | try { 128 | // Body is an array containing: 129 | // 1) Array of json string documents that don't have 130 | // attachments. These should be posted using the Bulk API. 131 | // 2) Responses of posting docs with attachments. 132 | $body = $this->parseAndSend($targetPath); 133 | try { 134 | fclose($this->sourceConnection); 135 | } catch (\Exception $e) { 136 | } 137 | 138 | return $body; 139 | } catch (\Exception $e) { 140 | throw $e; 141 | } 142 | } 143 | } 144 | 145 | /** 146 | * Read and return next line from the connection pointer. 147 | * $maxLength parameter can be used to set the maximum length 148 | * to be read. 149 | * 150 | * @param int $maxLength 151 | * 152 | * @return string 153 | */ 154 | protected function getNextLineFromSourceConnection($maxLength = null) 155 | { 156 | if ($maxLength !== null) { 157 | return fgets($this->sourceConnection, $maxLength); 158 | } else { 159 | return fgets($this->sourceConnection); 160 | } 161 | } 162 | 163 | /** 164 | * Parses multipart data. Returns an array having: 165 | * 1) Array of json docs(which are strings) that don't have attachments. 166 | * These should be posted using the Bulk API. 167 | * 2) Responses of posting docs with attachments. 168 | * 169 | * @param $targetPath 170 | * 171 | * @throws \Exception 172 | * @throws \HTTPException 173 | * 174 | * @return array 175 | */ 176 | protected function parseAndSend($targetPath) 177 | { 178 | // Main response boundary of the multipart stream. 179 | $mainBoundary = trim($this->getNextLineFromSourceConnection()); 180 | // If on the first line we have the size, then main boundary should be 181 | // on the second line. 182 | if (is_numeric($mainBoundary)) { 183 | $mainBoundary = trim($this->getNextLineFromSourceConnection()); 184 | } 185 | 186 | // Docs that don't have attachment. 187 | // These should be posted using Bulk upload. 188 | $docStack = []; 189 | 190 | // Responses from posting docs that have attachments. 191 | $responses = []; 192 | 193 | while (!feof($this->sourceConnection)) { 194 | $line = ltrim($this->getNextLineFromSourceConnection()); 195 | if ($line == '') { 196 | continue; 197 | } elseif (strpos($line, 'Content-Type') !== false) { 198 | list($header, $value) = explode(':', $line); 199 | $header = trim($header); 200 | $value = trim($value); 201 | $boundary = ''; 202 | 203 | if (strpos($value, ';') !== false) { 204 | list($type, $info) = explode(';', $value, 2); 205 | $info = trim($info); 206 | 207 | // Get the boundary for the current doc. 208 | if (strpos($info, 'boundary') !== false) { 209 | $boundary = $info; 210 | } elseif (strpos($info, 'error') !== false) { 211 | 212 | // Missing revs at the source. Continue till the end 213 | // of this document. 214 | while (strpos($this->getNextLineFromSourceConnection(), $mainBoundary) === false); 215 | continue; 216 | } else { 217 | throw new \Exception('Unknown parameter with Content-Type.'); 218 | } 219 | } 220 | // Doc with attachments. 221 | if (strpos($value, 'multipart/related') !== false) { 222 | if ($boundary == '') { 223 | throw new \Exception('Boundary not set for multipart/related data.'); 224 | } 225 | 226 | $boundary = explode('=', $boundary, 2)[1]; 227 | 228 | try { 229 | $responses[] = $this->sendStream( 230 | 'PUT', 231 | $targetPath, 232 | $mainBoundary, 233 | ['Content-Type' => 'multipart/related; boundary='.$boundary]); 234 | } catch (\Exception $e) { 235 | $responses[] = $e; 236 | } 237 | } elseif ($value == 'application/json') { 238 | // JSON doc without attachment. 239 | $jsonDoc = ''; 240 | 241 | while (trim(($jsonDoc = $this->getNextLineFromSourceConnection())) == ''); 242 | array_push($docStack, trim($jsonDoc)); 243 | 244 | // Continue till the end of this document. 245 | while (strpos($this->getNextLineFromSourceConnection(), $mainBoundary) === false); 246 | } else { 247 | throw new \UnexpectedValueException('This value is not supported.'); 248 | } 249 | } else { 250 | throw new \Exception('The first line is not the Content-Type.'); 251 | } 252 | } 253 | 254 | return [$docStack, $responses]; 255 | } 256 | 257 | /** 258 | * Reads multipart data from sourceConnection and streams it to the 259 | * targetConnection.Returns the body of the request or the status code in 260 | * case there is no body. 261 | * 262 | * @param $method 263 | * @param $path 264 | * @param $streamEnd 265 | * @param array $requestHeaders 266 | * 267 | * @throws \Exception 268 | * @throws \HTTPException 269 | * 270 | * @return mixed|string 271 | */ 272 | protected function sendStream( 273 | $method, 274 | $path, 275 | $streamEnd, 276 | $requestHeaders = [] 277 | ) { 278 | $dataStream = $this->sourceConnection; 279 | 280 | // Read the json doc. Use _attachments field to find the total 281 | // Content-Length and create the request header with initial doc data. 282 | // At present CouchDB can't handle chunked data and needs 283 | // Content-Length header. 284 | $str = ''; 285 | $jsonFlag = 0; 286 | $attachmentCount = 0; 287 | $totalAttachmentLength = 0; 288 | $streamLine = $this->getNextLineFromSourceConnection(); 289 | while ( 290 | $jsonFlag == 0 || 291 | ($jsonFlag == 1 && 292 | trim($streamLine) == '' 293 | ) 294 | ) { 295 | $str .= $streamLine; 296 | if (strpos($streamLine, 'Content-Type: application/json') !== false) { 297 | $jsonFlag = 1; 298 | } 299 | $streamLine = $this->getNextLineFromSourceConnection(); 300 | } 301 | $docBoundaryLength = strlen(explode('=', $requestHeaders['Content-Type'], 2)[1]) + 2; 302 | $json = json_decode($streamLine, true); 303 | foreach ($json['_attachments'] as $docName => $metaData) { 304 | // Quotes and a "/r/n" 305 | $totalAttachmentLength += strlen('Content-Disposition: attachment; filename=') + strlen($docName) + 4; 306 | $totalAttachmentLength += strlen('Content-Type: ') + strlen($metaData['content_type']) + 2; 307 | $totalAttachmentLength += strlen('Content-Length: '); 308 | if (isset($metaData['encoding'])) { 309 | $totalAttachmentLength += $metaData['encoded_length'] + strlen($metaData['encoded_length']) + 2; 310 | $totalAttachmentLength += strlen('Content-Encoding: ') + strlen($metaData['encoding']) + 2; 311 | } else { 312 | $totalAttachmentLength += $metaData['length'] + strlen($metaData['length']) + 2; 313 | } 314 | $totalAttachmentLength += 2; 315 | $attachmentCount++; 316 | } 317 | 318 | // Add Content-Length to the headers. 319 | $requestHeaders['Content-Length'] = strlen($str) + strlen($streamLine) 320 | + $totalAttachmentLength + $attachmentCount * (2 + $docBoundaryLength) + $docBoundaryLength + 2; 321 | 322 | if ($this->targetConnection == null) { 323 | $this->targetConnection = $this->targetClient->getConnection( 324 | $method, 325 | $path, 326 | null, 327 | $requestHeaders 328 | ); 329 | } 330 | // Write the initial body data. 331 | fwrite($this->targetConnection, $str); 332 | 333 | // Write the rest of the data including attachments line by line or in 334 | // chunks. 335 | while (!feof($dataStream) && 336 | ($streamEnd === null || 337 | strpos($streamLine, $streamEnd) === 338 | false 339 | ) 340 | ) { 341 | $totalSent = 0; 342 | $length = strlen($streamLine); 343 | while ($totalSent != $length) { 344 | $sent = fwrite($this->targetConnection, substr($streamLine, $totalSent)); 345 | if ($sent === false) { 346 | throw new \HTTPException('Stream write error.'); 347 | } else { 348 | $totalSent += $sent; 349 | } 350 | } 351 | // Use maxLength while reading the data as there may be no newlines 352 | // in the binary and compressed attachments, or the lines may be 353 | // very long. 354 | $streamLine = $this->getNextLineFromSourceConnection(100000); 355 | } 356 | 357 | // Read response headers 358 | $rawHeaders = ''; 359 | $headers = [ 360 | 'connection' => ($this->targetClient->getOptions()['keep-alive'] ? 'Keep-Alive' : 'Close'), 361 | ]; 362 | 363 | // Remove leading newlines, should not occur at all, actually. 364 | while ((($line = fgets($this->targetConnection)) !== false) && 365 | (($lineContent = rtrim($line)) === '')); 366 | 367 | // Throw exception, if connection has been aborted by the server, and 368 | // leave handling to the user for now. 369 | if ($line === false) { 370 | // sendStream can't be called in recursion as the source stream can be 371 | // read only once. 372 | $error = error_get_last(); 373 | throw HTTPException::connectionFailure( 374 | $this->targetClient->getOptions()['ip'], 375 | $this->targetClient->getOptions()['port'], 376 | $error['message'], 377 | 0 378 | ); 379 | } 380 | 381 | do { 382 | // Also store raw headers for later logging 383 | $rawHeaders .= $lineContent."\n"; 384 | // Extract header values 385 | if (preg_match('(^HTTP/(?P\d+\.\d+)\s+(?P\d+))S', $lineContent, $match)) { 386 | $headers['version'] = $match['version']; 387 | $headers['status'] = (int) $match['status']; 388 | } else { 389 | list($key, $value) = explode(':', $lineContent, 2); 390 | $headers[strtolower($key)] = ltrim($value); 391 | } 392 | } while ((($line = fgets($this->targetConnection)) !== false) && 393 | (($lineContent = rtrim($line)) !== '')); 394 | 395 | // Read response body 396 | $body = ''; 397 | 398 | // HTTP 1.1 supports chunked transfer encoding, if the according 399 | // header is not set, just read the specified amount of bytes. 400 | $bytesToRead = (int) (isset($headers['content-length']) ? $headers['content-length'] : 0); 401 | // Read body only as specified by chunk sizes, everything else 402 | // are just footnotes, which are not relevant for us. 403 | while ($bytesToRead > 0) { 404 | $body .= $read = fgets($this->targetConnection, $bytesToRead + 1); 405 | $bytesToRead -= strlen($read); 406 | } 407 | 408 | // Reset the connection if the server asks for it. 409 | if ($headers['connection'] !== 'Keep-Alive') { 410 | fclose($this->targetConnection); 411 | $this->targetConnection = null; 412 | } 413 | // Handle some response state as special cases 414 | switch ($headers['status']) { 415 | case 301: 416 | case 302: 417 | case 303: 418 | case 307: 419 | // Temporary redirect. 420 | // sendStream can't be called in recursion as the source stream can be 421 | // read only once. 422 | throw HTTPException::fromResponse($path, new Response($headers['status'], $headers, $body)); 423 | } 424 | 425 | return $body != '' ? json_decode($body, true) : ['status' => $headers['status']]; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /lib/Doctrine/CouchDB/CouchDBClient.php: -------------------------------------------------------------------------------- 1 | 22 | * @author Lukas Kahwe Smith 23 | */ 24 | class CouchDBClient 25 | { 26 | /** string \ufff0 */ 27 | const COLLATION_END = "\xEF\xBF\xB0"; 28 | 29 | /** 30 | * Name of the CouchDB database. 31 | * 32 | * @string 33 | */ 34 | protected $databaseName; 35 | 36 | /** 37 | * The underlying HTTP Connection of the used DocumentManager. 38 | * 39 | * @var Client 40 | */ 41 | protected $httpClient; 42 | 43 | /** 44 | * CouchDB Version. 45 | * 46 | * @var string 47 | */ 48 | protected $version = null; 49 | 50 | protected static $clients = [ 51 | 'socket' => 'Doctrine\CouchDB\HTTP\SocketClient', 52 | 'stream' => 'Doctrine\CouchDB\HTTP\StreamClient', 53 | ]; 54 | 55 | /** 56 | * Factory method for CouchDBClients. 57 | * 58 | * @param array $options 59 | * 60 | * @throws \InvalidArgumentException 61 | * 62 | * @return CouchDBClient 63 | */ 64 | public static function create(array $options) 65 | { 66 | if (isset($options['url'])) { 67 | $urlParts = parse_url($options['url']); 68 | 69 | foreach ($urlParts as $part => $value) { 70 | switch ($part) { 71 | case 'host': 72 | case 'user': 73 | case 'port': 74 | $options[$part] = $value; 75 | break; 76 | 77 | case 'path': 78 | $path = explode('/', $value); 79 | $options['dbname'] = array_pop($path); 80 | $options['path'] = trim(implode('/', $path), '/'); 81 | break; 82 | 83 | case 'pass': 84 | $options['password'] = $value; 85 | break; 86 | 87 | case 'scheme': 88 | $options['ssl'] = ($value === 'https'); 89 | break; 90 | 91 | default: 92 | break; 93 | } 94 | } 95 | } 96 | 97 | if (!isset($options['dbname'])) { 98 | throw new \InvalidArgumentException("'dbname' is a required option to create a CouchDBClient"); 99 | } 100 | 101 | $defaults = [ 102 | 'type' => 'socket', 103 | 'host' => 'localhost', 104 | 'port' => 5984, 105 | 'user' => null, 106 | 'password' => null, 107 | 'ip' => null, 108 | 'ssl' => false, 109 | 'path' => null, 110 | 'logging' => false, 111 | 'timeout' => 10, 112 | 'headers' => [], 113 | ]; 114 | $options = array_merge($defaults, $options); 115 | 116 | if (!isset(self::$clients[$options['type']])) { 117 | throw new \InvalidArgumentException(sprintf('There is no client implementation registered for %s, valid options are: %s', 118 | $options['type'], implode(', ', array_keys(self::$clients)) 119 | )); 120 | } 121 | $connectionClass = self::$clients[$options['type']]; 122 | $connection = new $connectionClass( 123 | $options['host'], 124 | $options['port'], 125 | $options['user'], 126 | $options['password'], 127 | $options['ip'], 128 | $options['ssl'], 129 | $options['path'], 130 | $options['timeout'], 131 | $options['headers'] 132 | ); 133 | if ($options['logging'] === true) { 134 | $connection = new HTTP\LoggingClient($connection); 135 | } 136 | 137 | return new static($connection, $options['dbname']); 138 | } 139 | 140 | /** 141 | * @param Client $client 142 | * @param string $databaseName 143 | */ 144 | public function __construct(Client $client, $databaseName) 145 | { 146 | $this->httpClient = $client; 147 | $this->databaseName = $databaseName; 148 | } 149 | 150 | public function setHttpClient(Client $client) 151 | { 152 | $this->httpClient = $client; 153 | } 154 | 155 | /** 156 | * @return Client 157 | */ 158 | public function getHttpClient() 159 | { 160 | return $this->httpClient; 161 | } 162 | 163 | public function getDatabase() 164 | { 165 | return $this->databaseName; 166 | } 167 | 168 | /** 169 | * Let CouchDB generate an array of UUIDs. 170 | * 171 | * @param int $count 172 | * 173 | * @throws CouchDBException 174 | * 175 | * @return array 176 | */ 177 | public function getUuids($count = 1) 178 | { 179 | $count = (int) $count; 180 | $response = $this->httpClient->request('GET', '/_uuids?count='.$count); 181 | 182 | if ($response->status != 200) { 183 | throw new CouchDBException('Could not retrieve UUIDs from CouchDB.'); 184 | } 185 | 186 | return $response->body['uuids']; 187 | } 188 | 189 | 190 | /** 191 | * Find a document by ID and return the HTTP response. 192 | * 193 | * @param string $id 194 | * 195 | * @return HTTP\Response 196 | */ 197 | public function findDocument($id) 198 | { 199 | $documentPath = '/'.$this->databaseName.'/'.urlencode($id); 200 | 201 | return $this->httpClient->request('GET', $documentPath); 202 | } 203 | 204 | /** 205 | * Find documents of all or the specified revisions. 206 | * 207 | * If $revisions is an array containing the revisions to be fetched, only 208 | * the documents of those revisions are fetched. Else document of all 209 | * leaf revisions are fetched. 210 | * 211 | * @param string $docId 212 | * @param mixed $revisions 213 | * 214 | * @return HTTP\Response 215 | */ 216 | public function findRevisions($docId, $revisions = null) 217 | { 218 | $path = '/'.$this->databaseName.'/'.urlencode($docId); 219 | if (is_array($revisions)) { 220 | // Fetch documents of only the specified leaf revisions. 221 | $path .= '?open_revs='.json_encode($revisions); 222 | } else { 223 | // Fetch documents of all leaf revisions. 224 | $path .= '?open_revs=all'; 225 | } 226 | // Set the Accept header to application/json to get a JSON array in the 227 | // response's body. Without this the response is multipart/mixed stream. 228 | return $this->httpClient->request( 229 | 'GET', 230 | $path, 231 | null, 232 | false, 233 | ['Accept' => 'application/json'] 234 | ); 235 | } 236 | 237 | /** 238 | * Find many documents by passing their ids and return the HTTP response. 239 | * 240 | * @param array $ids 241 | * @param null $limit 242 | * @param null $offset 243 | * 244 | * @return HTTP\Response 245 | */ 246 | public function findDocuments(array $ids, $limit = null, $offset = null) 247 | { 248 | $allDocsPath = '/'.$this->databaseName.'/_all_docs?include_docs=true'; 249 | if ($limit) { 250 | $allDocsPath .= '&limit='.(int) $limit; 251 | } 252 | if ($offset) { 253 | $allDocsPath .= '&skip='.(int) $offset; 254 | } 255 | 256 | return $this->httpClient->request('POST', $allDocsPath, json_encode( 257 | ['keys' => array_values($ids)]) 258 | ); 259 | } 260 | 261 | /** 262 | * Get all documents. 263 | * 264 | * @param int|null $limit 265 | * @param string|null $startKey 266 | * @param string|null $endKey 267 | * @param int|null $skip 268 | * @param bool $descending 269 | * 270 | * @return HTTP\Response 271 | */ 272 | public function allDocs($limit = null, $startKey = null, $endKey = null, $skip = null, $descending = false) 273 | { 274 | $allDocsPath = '/'.$this->databaseName.'/_all_docs?include_docs=true'; 275 | if ($limit) { 276 | $allDocsPath .= '&limit='.(int) $limit; 277 | } 278 | if ($startKey) { 279 | $allDocsPath .= '&startkey="'.(string) $startKey.'"'; 280 | } 281 | if (!is_null($endKey)) { 282 | $allDocsPath .= '&endkey="'.(string) $endKey.'"'; 283 | } 284 | if (!is_null($skip) && (int) $skip > 0) { 285 | $allDocsPath .= '&skip='.(int) $skip; 286 | } 287 | if (!is_null($descending) && (bool) $descending === true) { 288 | $allDocsPath .= '&descending=true'; 289 | } 290 | 291 | return $this->httpClient->request('GET', $allDocsPath); 292 | } 293 | 294 | /** 295 | * Get the current version of CouchDB. 296 | * 297 | * @throws HTTPException 298 | * 299 | * @return string 300 | */ 301 | public function getVersion() 302 | { 303 | if ($this->version === null) { 304 | $response = $this->httpClient->request('GET', '/'); 305 | if ($response->status != 200) { 306 | throw HTTPException::fromResponse('/', $response); 307 | } 308 | 309 | $this->version = $response->body['version']; 310 | } 311 | 312 | return $this->version; 313 | } 314 | 315 | /** 316 | * Get all databases. 317 | * 318 | * @throws HTTPException 319 | * 320 | * @return array 321 | */ 322 | public function getAllDatabases() 323 | { 324 | $response = $this->httpClient->request('GET', '/_all_dbs'); 325 | if ($response->status != 200) { 326 | throw HTTPException::fromResponse('/_all_dbs', $response); 327 | } 328 | 329 | return $response->body; 330 | } 331 | 332 | /** 333 | * Create a new database. 334 | * 335 | * @param string $name 336 | * 337 | * @throws HTTPException 338 | * 339 | * @return void 340 | */ 341 | public function createDatabase($name) 342 | { 343 | $response = $this->httpClient->request('PUT', '/'.urlencode($name)); 344 | 345 | if ($response->status != 201) { 346 | throw HTTPException::fromResponse('/'.urlencode($name), $response); 347 | } 348 | } 349 | 350 | /** 351 | * Drop a database. 352 | * 353 | * @param string $name 354 | * 355 | * @throws HTTPException 356 | * 357 | * @return void 358 | */ 359 | public function deleteDatabase($name) 360 | { 361 | $response = $this->httpClient->request('DELETE', '/'.urlencode($name)); 362 | 363 | if ($response->status != 200 && $response->status != 404) { 364 | throw HTTPException::fromResponse('/'.urlencode($name), $response); 365 | } 366 | } 367 | 368 | /** 369 | * Get Information about a database. 370 | * 371 | * @param string $name 372 | * 373 | * @throws HTTPException 374 | * 375 | * @return array 376 | */ 377 | public function getDatabaseInfo($name = null) 378 | { 379 | $response = $this->httpClient->request('GET', '/'.($name ? urlencode($name) : $this->databaseName)); 380 | 381 | if ($response->status != 200) { 382 | throw HTTPException::fromResponse('/'.urlencode($name), $response); 383 | } 384 | 385 | return $response->body; 386 | } 387 | 388 | /** 389 | * Get changes. 390 | * 391 | * @param array $params 392 | * 393 | * @throws HTTPException 394 | * 395 | * @return array 396 | */ 397 | public function getChanges(array $params = []) 398 | { 399 | $path = '/'.$this->databaseName.'/_changes'; 400 | 401 | $method = ((!isset($params['doc_ids']) || $params['doc_ids'] == null) ? 'GET' : 'POST'); 402 | $response = ''; 403 | 404 | if ($method == 'GET') { 405 | foreach ($params as $key => $value) { 406 | if (isset($params[$key]) === true && is_bool($value) === true) { 407 | $params[$key] = ($value) ? 'true' : 'false'; 408 | } 409 | } 410 | if (count($params) > 0) { 411 | $query = http_build_query($params); 412 | $path = $path.'?'.$query; 413 | } 414 | $response = $this->httpClient->request('GET', $path); 415 | } else { 416 | $path .= '?filter=_doc_ids'; 417 | $response = $this->httpClient->request('POST', $path, json_encode($params)); 418 | } 419 | if ($response->status != 200) { 420 | throw HTTPException::fromResponse($path, $response); 421 | } 422 | 423 | return $response->body; 424 | } 425 | 426 | /** 427 | * Create a bulk updater instance. 428 | * 429 | * @return BulkUpdater 430 | */ 431 | public function createBulkUpdater() 432 | { 433 | return new BulkUpdater($this->httpClient, $this->databaseName); 434 | } 435 | 436 | /** 437 | * Execute a POST request against CouchDB inserting a new document, leaving the server to generate a uuid. 438 | * 439 | * @param array $data 440 | * 441 | * @throws HTTPException 442 | * 443 | * @return array 444 | */ 445 | public function postDocument(array $data) 446 | { 447 | $path = '/'.$this->databaseName; 448 | $response = $this->httpClient->request('POST', $path, json_encode($data)); 449 | 450 | if ($response->status != 201) { 451 | throw HTTPException::fromResponse($path, $response); 452 | } 453 | 454 | return [$response->body['id'], $response->body['rev']]; 455 | } 456 | 457 | /** 458 | * Execute a PUT request against CouchDB inserting or updating a document. 459 | * 460 | * @param array $data 461 | * @param string $id 462 | * @param string|null $rev 463 | * 464 | * @throws HTTPException 465 | * 466 | * @return array 467 | */ 468 | public function putDocument($data, $id, $rev = null) 469 | { 470 | $data['_id'] = $id; 471 | if ($rev) { 472 | $data['_rev'] = $rev; 473 | } 474 | 475 | $path = '/'.$this->databaseName.'/'.urlencode($id); 476 | $response = $this->httpClient->request('PUT', $path, json_encode($data)); 477 | 478 | if ($response->status != 201) { 479 | throw HTTPException::fromResponse($path, $response); 480 | } 481 | 482 | return [$response->body['id'], $response->body['rev']]; 483 | } 484 | 485 | /** 486 | * Delete a document. 487 | * 488 | * @param string $id 489 | * @param string $rev 490 | * 491 | * @throws HTTPException 492 | * 493 | * @return void 494 | */ 495 | public function deleteDocument($id, $rev) 496 | { 497 | $path = '/'.$this->databaseName.'/'.urlencode($id).'?rev='.$rev; 498 | $response = $this->httpClient->request('DELETE', $path); 499 | 500 | if ($response->status != 200) { 501 | throw HTTPException::fromResponse($path, $response); 502 | } 503 | } 504 | 505 | /** 506 | * @param string $designDocName 507 | * @param string $viewName 508 | * @param DesignDocument $designDoc 509 | * 510 | * @return View\Query 511 | */ 512 | public function createViewQuery($designDocName, $viewName, DesignDocument $designDoc = null) 513 | { 514 | return new View\Query($this->httpClient, $this->databaseName, $designDocName, $viewName, $designDoc); 515 | } 516 | 517 | /** 518 | * Create or update a design document from the given in memory definition. 519 | * 520 | * @param string $designDocName 521 | * @param DesignDocument $designDoc 522 | * 523 | * @return HTTP\Response 524 | */ 525 | public function createDesignDocument($designDocName, DesignDocument $designDoc) 526 | { 527 | $data = $designDoc->getData(); 528 | $data['_id'] = '_design/'.$designDocName; 529 | 530 | $documentPath = '/'.$this->databaseName.'/'.$data['_id']; 531 | $response = $this->httpClient->request('GET', $documentPath); 532 | 533 | if ($response->status == 200) { 534 | $docData = $response->body; 535 | $data['_rev'] = $docData['_rev']; 536 | } 537 | 538 | return $this->httpClient->request( 539 | 'PUT', 540 | sprintf('/%s/_design/%s', $this->databaseName, $designDocName), 541 | json_encode($data) 542 | ); 543 | } 544 | 545 | /** 546 | * GET /db/_compact. 547 | * 548 | * Return array of data about compaction status. 549 | * 550 | * @throws HTTPException 551 | * 552 | * @return array 553 | */ 554 | public function getCompactInfo() 555 | { 556 | $path = sprintf('/%s/_compact', $this->databaseName); 557 | $response = $this->httpClient->request('GET', $path); 558 | if ($response->status >= 400) { 559 | throw HTTPException::fromResponse($path, $response); 560 | } 561 | 562 | return $response->body; 563 | } 564 | 565 | /** 566 | * POST /db/_compact. 567 | * 568 | * @throws HTTPException 569 | * 570 | * @return array 571 | */ 572 | public function compactDatabase() 573 | { 574 | $path = sprintf('/%s/_compact', $this->databaseName); 575 | $response = $this->httpClient->request('POST', $path); 576 | if ($response->status >= 400) { 577 | throw HTTPException::fromResponse($path, $response); 578 | } 579 | 580 | return $response->body; 581 | } 582 | 583 | /** 584 | * POST /db/_compact/designDoc. 585 | * 586 | * @param string $designDoc 587 | * 588 | * @throws HTTPException 589 | * 590 | * @return array 591 | */ 592 | public function compactView($designDoc) 593 | { 594 | $path = sprintf('/%s/_compact/%s', $this->databaseName, $designDoc); 595 | $response = $this->httpClient->request('POST', $path); 596 | if ($response->status >= 400) { 597 | throw HTTPException::fromResponse($path, $response); 598 | } 599 | 600 | return $response->body; 601 | } 602 | 603 | /** 604 | * POST /db/_view_cleanup. 605 | * 606 | * @throws HTTPException 607 | * 608 | * @return array 609 | */ 610 | public function viewCleanup() 611 | { 612 | $path = sprintf('/%s/_view_cleanup', $this->databaseName); 613 | $response = $this->httpClient->request('POST', $path); 614 | if ($response->status >= 400) { 615 | throw HTTPException::fromResponse($path, $response); 616 | } 617 | 618 | return $response->body; 619 | } 620 | 621 | /** 622 | * POST /db/_replicate. 623 | * 624 | * @param string $source 625 | * @param string $target 626 | * @param bool|null $cancel 627 | * @param bool|null $continuous 628 | * @param string|null $filter 629 | * @param array|null $ids 630 | * @param string|null $proxy 631 | * 632 | * @throws HTTPException 633 | * 634 | * @return array 635 | */ 636 | public function replicate($source, $target, $cancel = null, $continuous = null, $filter = null, array $ids = null, $proxy = null) 637 | { 638 | $params = ['target' => $target, 'source' => $source]; 639 | if ($cancel !== null) { 640 | $params['cancel'] = (bool) $cancel; 641 | } 642 | if ($continuous !== null) { 643 | $params['continuous'] = (bool) $continuous; 644 | } 645 | if ($filter !== null) { 646 | $params['filter'] = $filter; 647 | } 648 | if ($ids !== null) { 649 | $params['doc_ids'] = $ids; 650 | } 651 | if ($proxy !== null) { 652 | $params['proxy'] = $proxy; 653 | } 654 | $path = '/_replicate'; 655 | $response = $this->httpClient->request('POST', $path, json_encode($params)); 656 | if ($response->status >= 400) { 657 | throw HTTPException::fromResponse($path, $response); 658 | } 659 | 660 | return $response->body; 661 | } 662 | 663 | /** 664 | * GET /_active_tasks. 665 | * 666 | * @throws HTTPException 667 | * 668 | * @return array 669 | */ 670 | public function getActiveTasks() 671 | { 672 | $response = $this->httpClient->request('GET', '/_active_tasks'); 673 | if ($response->status != 200) { 674 | throw HTTPException::fromResponse('/_active_tasks', $response); 675 | } 676 | 677 | return $response->body; 678 | } 679 | 680 | /** 681 | * Get revision difference. 682 | * 683 | * @param array $data 684 | * 685 | * @throws HTTPException 686 | * 687 | * @return array 688 | */ 689 | public function getRevisionDifference($data) 690 | { 691 | $path = '/'.$this->databaseName.'/_revs_diff'; 692 | $response = $this->httpClient->request('POST', $path, json_encode($data)); 693 | if ($response->status != 200) { 694 | throw HTTPException::fromResponse($path, $response); 695 | } 696 | 697 | return $response->body; 698 | } 699 | 700 | /** 701 | * Transfer missing revisions to the target. The Content-Type of response 702 | * from the source should be multipart/mixed. 703 | * 704 | * @param string $docId 705 | * @param array $missingRevs 706 | * @param CouchDBClient $target 707 | * 708 | * @throws HTTPException 709 | * 710 | * @return array|HTTP\ErrorResponse|string 711 | */ 712 | public function transferChangedDocuments($docId, $missingRevs, CouchDBClient $target) 713 | { 714 | $path = '/'.$this->getDatabase().'/'.$docId; 715 | $params = ['revs' => true, 'latest' => true, 'open_revs' => json_encode($missingRevs)]; 716 | $query = http_build_query($params); 717 | $path .= '?'.$query; 718 | 719 | $targetPath = '/'.$target->getDatabase().'/'.$docId.'?new_edits=false'; 720 | 721 | $mutltipartHandler = new MultipartParserAndSender($this->getHttpClient(), $target->getHttpClient()); 722 | 723 | return $mutltipartHandler->request( 724 | 'GET', 725 | $path, 726 | $targetPath, 727 | null, 728 | ['Accept' => 'multipart/mixed'] 729 | ); 730 | } 731 | 732 | /** 733 | * Get changes as a stream. 734 | * 735 | * This method similar to the getChanges() method. But instead of returning 736 | * the set of changes, it returns the connection stream from which the response 737 | * can be read line by line. This is useful when you want to continuously get changes 738 | * as they occur. Filtered changes feed is not supported by this method. 739 | * 740 | * @param array $params 741 | * @param bool $raw 742 | * 743 | * @throws HTTPException 744 | * 745 | * @return resource 746 | */ 747 | public function getChangesAsStream(array $params = []) 748 | { 749 | // Set feed to continuous. 750 | if (!isset($params['feed']) || $params['feed'] != 'continuous') { 751 | $params['feed'] = 'continuous'; 752 | } 753 | $path = '/'.$this->databaseName.'/_changes'; 754 | $connectionOptions = $this->getHttpClient()->getOptions(); 755 | $streamClient = new StreamClient( 756 | $connectionOptions['host'], 757 | $connectionOptions['port'], 758 | $connectionOptions['username'], 759 | $connectionOptions['password'], 760 | $connectionOptions['ip'], 761 | $connectionOptions['ssl'], 762 | $connectionOptions['path'] 763 | ); 764 | 765 | foreach ($params as $key => $value) { 766 | if (isset($params[$key]) === true && is_bool($value) === true) { 767 | $params[$key] = ($value) ? 'true' : 'false'; 768 | } 769 | } 770 | if (count($params) > 0) { 771 | $query = http_build_query($params); 772 | $path = $path.'?'.$query; 773 | } 774 | $stream = $streamClient->getConnection('GET', $path, null); 775 | 776 | $headers = $streamClient->getStreamHeaders($stream); 777 | if (empty($headers['status'])) { 778 | throw HTTPException::readFailure( 779 | $connectionOptions['ip'], 780 | $connectionOptions['port'], 781 | 'Received an empty response or not status code', 782 | 0 783 | ); 784 | } elseif ($headers['status'] != 200) { 785 | $body = ''; 786 | while (!feof($stream)) { 787 | $body .= fgets($stream); 788 | } 789 | throw HTTPException::fromResponse($path, new Response($headers['status'], $headers, $body)); 790 | } 791 | // Everything seems okay. Return the connection resource. 792 | return $stream; 793 | } 794 | 795 | /** 796 | * Commit any recent changes to the specified database to disk. 797 | * 798 | * @throws HTTPException 799 | * 800 | * @return array 801 | */ 802 | public function ensureFullCommit() 803 | { 804 | $path = '/'.$this->databaseName.'/_ensure_full_commit'; 805 | $response = $this->httpClient->request('POST', $path); 806 | if ($response->status != 201) { 807 | throw HTTPException::fromResponse($path, $response); 808 | } 809 | 810 | return $response->body; 811 | } 812 | } 813 | -------------------------------------------------------------------------------- /tests/Doctrine/Tests/CouchDB/Functional/CouchDBClientTest.php: -------------------------------------------------------------------------------- 1 | couchClient = $this->createCouchDBClient(); 18 | $this->couchClient->deleteDatabase($this->getTestDatabase()); 19 | sleep(0.5); 20 | $this->couchClient->createDatabase($this->getTestDatabase()); 21 | } 22 | 23 | public function testGetUuids() 24 | { 25 | $uuids = $this->couchClient->getUuids(); 26 | $this->assertEquals(1, count($uuids)); 27 | $this->assertEquals(32, strlen($uuids[0])); 28 | 29 | $uuids = $this->couchClient->getUuids(10); 30 | $this->assertEquals(10, count($uuids)); 31 | } 32 | 33 | public function testGetVersion() 34 | { 35 | $version = $this->couchClient->getVersion(); 36 | $this->assertEquals(3, count(explode('.', $version))); 37 | } 38 | 39 | public function testGetAllDatabases() 40 | { 41 | $dbs = $this->couchClient->getAllDatabases(); 42 | $this->assertContains($this->getTestDatabase(), $dbs); 43 | } 44 | 45 | public function testDeleteDatabase() 46 | { 47 | $this->couchClient->deleteDatabase($this->getTestDatabase()); 48 | 49 | $dbs = $this->couchClient->getAllDatabases(); 50 | $this->assertNotContains($this->getTestDatabase(), $dbs); 51 | } 52 | 53 | /** 54 | * @depends testDeleteDatabase 55 | */ 56 | public function testCreateDatabase() 57 | { 58 | $dbName2 = $this->getTestDatabase().'2'; 59 | $this->couchClient->deleteDatabase($dbName2); 60 | $this->couchClient->createDatabase($dbName2); 61 | 62 | $dbs = $this->couchClient->getAllDatabases(); 63 | $this->assertContains($dbName2, $dbs); 64 | 65 | // Tidy 66 | $this->couchClient->deleteDatabase($dbName2); 67 | } 68 | 69 | public function testDropMultipleTimesSkips() 70 | { 71 | $this->couchClient->deleteDatabase($this->getTestDatabase()); 72 | $this->couchClient->deleteDatabase($this->getTestDatabase()); 73 | } 74 | 75 | /** 76 | * @depends testCreateDatabase 77 | */ 78 | public function testCreateDuplicateDatabaseThrowsException() 79 | { 80 | $this->setExpectedException('Doctrine\CouchDB\HTTP\HTTPException', 'HTTP Error with status 412 occurred while requesting /'.$this->getTestDatabase().'. Error: file_exists The database could not be created, the file already exists.'); 81 | $this->couchClient->createDatabase($this->getTestDatabase()); 82 | } 83 | 84 | public function testGetDatabaseInfo() 85 | { 86 | $data = $this->couchClient->getDatabaseInfo($this->getTestDatabase()); 87 | 88 | $this->assertInternalType('array', $data); 89 | $this->assertArrayHasKey('db_name', $data); 90 | $this->assertEquals($this->getTestDatabase(), $data['db_name']); 91 | 92 | $notExistedDb = 'not_existed_db'; 93 | 94 | $this->setExpectedException('Doctrine\CouchDB\HTTP\HTTPException', 'HTTP Error with status 404 occurred while requesting /'.$notExistedDb.'. Error: not_found Database does not exist'); 95 | 96 | $this->couchClient->getDatabaseInfo($notExistedDb); 97 | } 98 | 99 | public function testCreateBulkUpdater() 100 | { 101 | $updater = $this->couchClient->createBulkUpdater(); 102 | $this->assertInstanceOf('Doctrine\CouchDB\Utils\BulkUpdater', $updater); 103 | } 104 | 105 | /** 106 | * @depends testCreateBulkUpdater 107 | */ 108 | public function testGetChanges() 109 | { 110 | if (version_compare($this->couchClient->getVersion(), '2.0.0') >= 0){ 111 | $this->markTestSkipped( 112 | 'This test will not pass on version 2.0.0 due a result order bug. https://github.com/apache/couchdb/issues/513' 113 | ); 114 | } 115 | 116 | $updater = $this->couchClient->createBulkUpdater(); 117 | $updater->updateDocument(['_id' => 'test1', 'foo' => 'bar']); 118 | $updater->updateDocument(['_id' => 'test2', 'bar' => 'baz']); 119 | $updater->execute(); 120 | 121 | $changes = $this->couchClient->getChanges(); 122 | 123 | $this->assertArrayHasKey('results', $changes); 124 | 125 | $this->assertEquals(2, count($changes['results'])); 126 | $this->assertStringStartsWith('2', $changes['last_seq']); 127 | 128 | // Check the doc_ids parameter. 129 | $changes = $this->couchClient->getChanges([ 130 | 'doc_ids' => ['test1'], 131 | ]); 132 | 133 | $this->assertArrayHasKey('results', $changes); 134 | $this->assertEquals(1, count($changes['results'])); 135 | $this->assertArrayHasKey('id', $changes['results'][0]); 136 | $this->assertEquals('test1', $changes['results'][0]['id']); 137 | $this->assertStringStartsWith('2', $changes['last_seq']); 138 | 139 | $changes = $this->couchClient->getChanges([ 140 | 'doc_ids' => null, 141 | ]); 142 | $this->assertArrayHasKey('results', $changes); 143 | $this->assertEquals(2, count($changes['results'])); 144 | $this->assertStringStartsWith('2', $changes['last_seq']); 145 | 146 | // Check the limit parameter. 147 | $changes = $this->couchClient->getChanges([ 148 | 'limit' => 1, 149 | ]); 150 | 151 | $this->assertArrayHasKey('results', $changes); 152 | $this->assertEquals(1, count($changes['results'])); 153 | $this->assertStringStartsWith('1', $changes['last_seq']); 154 | 155 | // Checks the descending parameter. 156 | $changes = $this->couchClient->getChanges([ 157 | 'descending' => true, 158 | ]); 159 | 160 | $this->assertArrayHasKey('results', $changes); 161 | $this->assertEquals(2, count($changes['results'])); 162 | $this->assertStringStartsWith('1', $changes['last_seq']); 163 | 164 | // Checks the since parameter. 165 | $changes = $this->couchClient->getChanges([ 166 | 'since' => 1, 167 | ]); 168 | 169 | $this->assertArrayHasKey('results', $changes); 170 | $this->assertEquals(1, count($changes['results'])); 171 | $this->assertStringStartsWith('2', $changes['last_seq']); 172 | 173 | // Checks the filter parameter. 174 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 175 | 176 | // Create a filter, that filters the only doc with {"_id":"test1"} 177 | $client = $this->couchClient; 178 | $client->createDesignDocument('test-filter', new FolderDesignDocument($designDocPath)); 179 | 180 | $changes = $this->couchClient->getChanges([ 181 | 'filter' => 'test-filter/my_filter', 182 | ]); 183 | $this->assertEquals(1, count($changes['results'])); 184 | $this->assertStringStartsWith('3', $changes['last_seq']); 185 | } 186 | 187 | public function testPostDocument() 188 | { 189 | $client = $this->couchClient; 190 | list($id, $rev) = $client->postDocument(['foo' => 'bar']); 191 | 192 | $response = $client->findDocument($id); 193 | $this->assertEquals(['_id' => $id, '_rev' => $rev, 'foo' => 'bar'], $response->body); 194 | } 195 | 196 | public function testPutDocument() 197 | { 198 | $id = 'foo-bar-baz'; 199 | $client = $this->couchClient; 200 | list($id, $rev) = $client->putDocument(['foo' => 'bar'], $id); 201 | 202 | $response = $client->findDocument($id); 203 | $this->assertEquals(['_id' => $id, '_rev' => $rev, 'foo' => 'bar'], $response->body); 204 | 205 | list($id, $rev) = $client->putDocument(['foo' => 'baz'], $id, $rev); 206 | 207 | $response = $client->findDocument($id); 208 | $this->assertEquals(['_id' => $id, '_rev' => $rev, 'foo' => 'baz'], $response->body); 209 | } 210 | 211 | public function testDeleteDocument() 212 | { 213 | $client = $this->couchClient; 214 | list($id, $rev) = $client->postDocument(['foo' => 'bar']); 215 | 216 | $client->deleteDocument($id, $rev); 217 | 218 | $response = $client->findDocument($id); 219 | $this->assertEquals(404, $response->status); 220 | } 221 | 222 | public function testCreateDesignDocument() 223 | { 224 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 225 | 226 | $client = $this->couchClient; 227 | $client->createDesignDocument('test-design-doc-create', new FolderDesignDocument($designDocPath)); 228 | 229 | $response = $client->findDocument('_design/test-design-doc-create'); 230 | $this->assertEquals(200, $response->status); 231 | } 232 | 233 | public function testCreateViewQuery() 234 | { 235 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 236 | 237 | $client = $this->couchClient; 238 | $designDoc = new FolderDesignDocument($designDocPath); 239 | 240 | $query = $client->createViewQuery('test-design-doc-query', 'username', $designDoc); 241 | $this->assertInstanceOf('Doctrine\CouchDB\View\Query', $query); 242 | 243 | $result = $query->execute(); 244 | 245 | $this->assertInstanceOf('Doctrine\CouchDB\View\Result', $result); 246 | $this->assertEquals(0, $result->getOffset()); 247 | $this->assertEquals(0, $result->getTotalRows()); 248 | $this->assertEquals(0, count($result)); 249 | } 250 | 251 | public function testQueryWithKeys() 252 | { 253 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 254 | 255 | $client = $this->couchClient; 256 | $ids = []; 257 | for ($i = 0; $i < 10; $i++) { 258 | $data = [ 259 | 'type' => 'Doctrine.Tests.Models.CMS.CmsUser', 260 | 'username' => "user-$i", 261 | ]; 262 | list($id, $rev) = $client->putDocument($data, "query-with-key-$i"); 263 | $ids[] = $id; 264 | } 265 | 266 | $designDoc = new FolderDesignDocument($designDocPath); 267 | 268 | $query = $client->createViewQuery('test-design-doc-query', 'username', $designDoc); 269 | $query->setKeys($ids); 270 | 271 | $this->assertInstanceOf('Doctrine\CouchDB\View\Query', $query); 272 | 273 | $result = $query->execute(); 274 | 275 | $this->assertEquals(10, $result->getTotalRows()); 276 | } 277 | 278 | public function testCompactDatabase() 279 | { 280 | $client = $this->couchClient; 281 | $client->compactDatabase(); 282 | } 283 | 284 | public function testCompactView() 285 | { 286 | $client = $this->couchClient; 287 | 288 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 289 | 290 | $client = $this->couchClient; 291 | $designDoc = new FolderDesignDocument($designDocPath); 292 | 293 | $query = $client->createViewQuery('test-design-doc-query', 'username', $designDoc); 294 | $result = $query->execute(); 295 | 296 | $client->compactView('test-design-doc-query'); 297 | } 298 | 299 | public function testFindDocument() 300 | { 301 | $client = $this->couchClient; 302 | // Test fetching of document. 303 | list($id, $rev) = $client->postDocument(['foo' => 'bar']); 304 | $response = $client->findDocument($id); 305 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 306 | $this->assertObjectHasAttribute('body', $response); 307 | $body = $response->body; 308 | $this->assertEquals( 309 | ['_id' => $id, '_rev' => $rev, 'foo' => 'bar'], 310 | $body 311 | ); 312 | } 313 | 314 | /** 315 | * @depends testCreateBulkUpdater 316 | */ 317 | public function testFindRevisions() 318 | { 319 | $client = $this->couchClient; 320 | 321 | // The _id of all the documents is same. So we will get multiple leaf 322 | // revisions. 323 | $id = 'multiple_revisions'; 324 | $docs = [ 325 | ['_id' => $id, '_rev' => '1-abc', 'foo' => 'bar1'], 326 | ['_id' => $id, '_rev' => '1-bcd', 'foo' => 'bar2'], 327 | ['_id' => $id, '_rev' => '1-cde', 'foo' => 'bar3'], 328 | ]; 329 | 330 | // Add the documents to the test db using Bulk API. 331 | $updater = $this->couchClient->createBulkUpdater(); 332 | $updater->updateDocuments($docs); 333 | // Set newedits to false to use the supplied _rev instead of assigning 334 | // new ones. 335 | $updater->setNewEdits(false); 336 | $response = $updater->execute(); 337 | 338 | // Test fetching of documents of all revisions. By default all 339 | // revisions are fetched. 340 | $response = $client->findRevisions($id); 341 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 342 | $this->assertObjectHasAttribute('body', $response); 343 | $expected = [ 344 | ['ok' => $docs[0]], 345 | ['ok' => $docs[1]], 346 | ['ok' => $docs[2]], 347 | ]; 348 | 349 | $this->assertEquals($expected, $response->body); 350 | // Test fetching of specific revisions. 351 | $response = $client->findRevisions( 352 | $id, 353 | ['1-abc', '1-cde', '100-ghfgf', '200-blah'] 354 | ); 355 | 356 | $this->assertInstanceOf('\Doctrine\CouchDB\HTTP\Response', $response); 357 | $this->assertObjectHasAttribute('body', $response); 358 | 359 | $body = $response->body; 360 | $this->assertEquals(4, count($body)); 361 | // Doc with _rev = 1-abc. 362 | $this->assertEquals($docs[0], $body[0]['ok']); 363 | // Doc with _rev = 1-cde. 364 | $this->assertEquals($docs[2], $body[1]['ok']); 365 | 366 | // Missing revisions. 367 | $this->assertEquals(['missing' => '100-ghfgf'], $body[2]); 368 | $this->assertEquals(['missing' => '200-blah'], $body[3]); 369 | } 370 | 371 | public function testFindDocuments() 372 | { 373 | $client = $this->couchClient; 374 | 375 | $ids = []; 376 | $expectedRows = []; 377 | foreach (range(1, 3) as $i) { 378 | list($id, $rev) = $client->postDocument(['foo' => 'bar'.$i]); 379 | $ids[] = $id; 380 | // This structure might be dependent from couchdb version. Tested against v2.0.0 381 | $expectedRows[] = [ 382 | 'id' => $id, 383 | 'key' => $id, 384 | 'value' => [ 385 | 'rev' => $rev, 386 | ], 387 | 'doc' => [ 388 | '_id' => $id, 389 | '_rev' => $rev, 390 | 'foo' => 'bar'.$i, 391 | ], 392 | ]; 393 | } 394 | 395 | $response = $client->findDocuments($ids); 396 | 397 | $this->assertEquals(['total_rows' => 3, 'rows' => $expectedRows], $response->body); 398 | 399 | $response = $client->findDocuments($ids, 0); 400 | $this->assertEquals(['total_rows' => 3, 'rows' => $expectedRows], $response->body); 401 | 402 | $response = $client->findDocuments($ids, 1); 403 | $this->assertEquals(['total_rows' => 3, 'rows' => [$expectedRows[0]]], $response->body); 404 | 405 | $response = $client->findDocuments($ids, 0, 2); 406 | $this->assertEquals(['total_rows' => 3, 'rows' => [$expectedRows[2]]], $response->body); 407 | 408 | $response = $client->findDocuments($ids, 1, 1); 409 | $this->assertEquals(['total_rows' => 3, 'rows' => [$expectedRows[1]]], $response->body); 410 | } 411 | 412 | public function testAllDocs() 413 | { 414 | $client = $this->couchClient; 415 | 416 | $ids = []; 417 | $expectedRows = []; 418 | foreach (range(1, 3) as $i) { 419 | list($id, $rev) = $client->postDocument(['foo' => 'bar'.$i]); 420 | $ids[] = $id; 421 | // This structure might be dependent from couchdb version. Tested against v2.0.0 422 | $expectedRows[] = [ 423 | 'id' => $id, 424 | 'value' => [ 425 | 'rev' => $rev, 426 | ], 427 | 'doc' => [ 428 | '_id' => $id, 429 | '_rev' => $rev, 430 | 'foo' => 'bar'.$i, 431 | ], 432 | 'key' => $id, 433 | ]; 434 | } 435 | 436 | // Everything 437 | $response = $client->allDocs(); 438 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => $expectedRows], $response->body); 439 | 440 | // No Limit 441 | $response = $client->allDocs(0); 442 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => $expectedRows], $response->body); 443 | 444 | // Limit 445 | $response = $client->allDocs(1); 446 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => [$expectedRows[0]]], $response->body); 447 | 448 | // Limit 449 | $response = $client->allDocs(2); 450 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => [$expectedRows[0], $expectedRows[1]]], $response->body); 451 | 452 | // Start Key 453 | $response = $client->allDocs(0, $ids[1]); 454 | $this->assertEquals(['total_rows' => 3, 'offset' => 1, 'rows' => [$expectedRows[1], $expectedRows[2]]], $response->body); 455 | 456 | // Start Key with Limit 457 | $response = $client->allDocs(1, $ids[2]); 458 | $this->assertEquals(['total_rows' => 3, 'offset' => 2, 'rows' => [$expectedRows[2]]], $response->body); 459 | 460 | // End key 461 | $response = $client->allDocs(0, null, $ids[0]); 462 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => [$expectedRows[0]]], $response->body); 463 | 464 | // Skip 465 | $response = $client->allDocs(0, null, null, 1); 466 | $this->assertEquals(['total_rows' => 3, 'offset' => 1, 'rows' => [$expectedRows[1], $expectedRows[2]]], $response->body); 467 | 468 | // Skip, Descending 469 | $response = $client->allDocs(null, null, null, 1, true); 470 | $this->assertEquals(['total_rows' => 3, 'offset' => 1, 'rows' => [$expectedRows[1], $expectedRows[0]]], $response->body); 471 | 472 | // Limit, Descending 473 | $response = $client->allDocs(1, null, null, null, true); 474 | $this->assertEquals(['total_rows' => 3, 'offset' => 0, 'rows' => [$expectedRows[2]]], $response->body); 475 | 476 | // tidy 477 | $client->deleteDocument($expectedRows[0]['id'], $expectedRows[0]['value']['rev']); 478 | $client->deleteDocument($expectedRows[1]['id'], $expectedRows[1]['value']['rev']); 479 | $client->deleteDocument($expectedRows[2]['id'], $expectedRows[2]['value']['rev']); 480 | } 481 | 482 | public function testGetActiveTasks() 483 | { 484 | $client = $this->couchClient; 485 | $active_tasks = $client->getActiveTasks(); 486 | $this->assertEquals([], $active_tasks); 487 | 488 | $sourceDatabase = $this->getTestDatabase(); 489 | $targetDatabase1 = $this->getTestDatabase().'target1'; 490 | $targetDatabase2 = $this->getTestDatabase().'target2'; 491 | $this->couchClient->deleteDatabase($targetDatabase1); 492 | $this->couchClient->deleteDatabase($targetDatabase2); 493 | $this->couchClient->createDatabase($targetDatabase1); 494 | $this->couchClient->createDatabase($targetDatabase2); 495 | 496 | $client->replicate($sourceDatabase, $targetDatabase1, null, true); 497 | //Receiving empty array when requesting straight away 498 | sleep(5); 499 | $active_tasks = $client->getActiveTasks(true); 500 | 501 | $this->assertTrue(count($active_tasks) == 1); 502 | 503 | $client->replicate($sourceDatabase, $targetDatabase2, null, true); 504 | sleep(5); 505 | $active_tasks = $client->getActiveTasks(); 506 | $this->assertTrue(count($active_tasks) == 2); 507 | 508 | $client->replicate($sourceDatabase, $targetDatabase1, true, true); 509 | $client->replicate($sourceDatabase, $targetDatabase2, true, true); 510 | 511 | sleep(5); 512 | $active_tasks = $client->getActiveTasks(); 513 | $this->assertEquals([], $active_tasks); 514 | 515 | // Tidy 516 | $this->couchClient->deleteDatabase($targetDatabase1); 517 | $this->couchClient->deleteDatabase($targetDatabase2); 518 | } 519 | 520 | public function testGetRevisionDifference() 521 | { 522 | $client = $this->couchClient; 523 | $mapping = [ 524 | 'baz' => [ 525 | 0 => '2-7051cbe5c8faecd085a3fa619e6e6337', 526 | ], 527 | 'foo' => [ 528 | 0 => '3-6a540f3d701ac518d3b9733d673c5484', 529 | ], 530 | 'bar' => [ 531 | 0 => '1-d4e501ab47de6b2000fc8a02f84a0c77', 532 | 1 => '1-967a00dff5e02add41819138abb3284d', 533 | ], 534 | ]; 535 | $revisionDifference = [ 536 | 'baz' => [ 537 | 'missing' => [ 538 | 0 => '2-7051cbe5c8faecd085a3fa619e6e6337', 539 | ], 540 | ], 541 | 'foo' => [ 542 | 'missing' => [ 543 | 0 => '3-6a540f3d701ac518d3b9733d673c5484', 544 | ], 545 | ], 546 | 'bar' => [ 547 | 'missing' => [ 548 | 0 => '1-d4e501ab47de6b2000fc8a02f84a0c77', 549 | 1 => '1-967a00dff5e02add41819138abb3284d', 550 | ], 551 | ], 552 | ]; 553 | 554 | list($id, $rev) = $client->putDocument(['name' => 'test'], 'foo'); 555 | $mapping['foo'][] = $rev; 556 | $revDiff = $client->getRevisionDifference($mapping); 557 | if (isset($revDiff['foo']['possible_ancestors'])) { 558 | $revisionDifference['foo']['possible_ancestors'] = $revDiff['foo']['possible_ancestors']; 559 | } 560 | $this->assertEquals($revisionDifference, $revDiff); 561 | } 562 | 563 | /** 564 | * @depends testCreateBulkUpdater 565 | */ 566 | public function testTransferChangedDocuments() 567 | { 568 | $client = $this->couchClient; 569 | 570 | // Doc id. 571 | $id = 'multiple_attachments'; 572 | // Document with attachments. 573 | $docWithAttachment = [ 574 | '_id' => $id, 575 | '_rev' => '1-abc', 576 | '_attachments' => [ 577 | 'foo.txt' => [ 578 | 'content_type' => 'text/plain', 579 | 'data' => 'VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=', 580 | ], 581 | 'bar.txt' => [ 582 | 'content_type' => 'text/plain', 583 | 'data' => 'VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=', 584 | ], 585 | ], 586 | ]; 587 | // Doc without any attachment. The id of both the docs is same. 588 | // So we will get two leaf revisions. 589 | $doc = ['_id' => $id, 'foo' => 'bar', '_rev' => '1-bcd']; 590 | 591 | // Add the documents to the test db using Bulk API. 592 | $updater = $this->couchClient->createBulkUpdater(); 593 | $updater->updateDocument($docWithAttachment); 594 | $updater->updateDocument($doc); 595 | // Set newedits to false to use the supplied _rev instead of assigning 596 | // new ones. 597 | $updater->setNewEdits(false); 598 | $response = $updater->execute(); 599 | 600 | // Create the copy database and a copyClient to interact with it. 601 | $copyDb = $this->getTestDatabase().'_copy'; 602 | 603 | $this->couchClient->deleteDatabase($copyDb); 604 | sleep(0.5); 605 | $this->couchClient->createDatabase($copyDb); 606 | 607 | $copyClient = new CouchDBClient($client->getHttpClient(), $copyDb); 608 | 609 | // Missing revisions in the $copyDb. 610 | $missingRevs = ['1-abc', '1-bcd']; 611 | // Transfer the missing revisions from the source to the target. 612 | 613 | $response = $client->transferChangedDocuments($id, $missingRevs, $copyClient, true); 614 | 615 | list($docStack, $responses) = $client->transferChangedDocuments($id, $missingRevs, $copyClient); 616 | // $docStack should contain the doc that didn't have the attachment. 617 | $this->assertEquals(1, count($docStack)); 618 | $this->assertEquals($doc, json_decode($docStack[0], true)); 619 | 620 | // The doc with attachment should have been copied to the copyDb. 621 | $this->assertEquals(1, count($responses)); 622 | $this->assertArrayHasKey('ok', $responses[0]); 623 | $this->assertEquals(true, $responses[0]['ok']); 624 | $client->deleteDatabase($copyDb); 625 | } 626 | 627 | /** 628 | * @depends testGetChanges 629 | */ 630 | public function testGetChangesAsStream() 631 | { 632 | $client = $this->couchClient; 633 | 634 | // Stream of changes feed. 635 | $stream = $client->getChangesAsStream(); 636 | list($id, $rev) = $client->postDocument(['_id' => 'stream1', 'foo' => 'bar']); 637 | // Get the change feed data for stream1. 638 | while (trim($line = fgets($stream)) == ''); 639 | $this->assertEquals('stream1', json_decode($line, true)['id']); 640 | list($id, $rev) = $client->postDocument(['_id' => 'stream2', 'foo' => 'bar']); 641 | // Get the change feed data for stream2. 642 | while (trim($line = fgets($stream)) == ''); 643 | $this->assertEquals('stream2', json_decode($line, true)['id']); 644 | fclose($stream); 645 | } 646 | 647 | public function testEnsureFullCommit() 648 | { 649 | $client = $this->couchClient; 650 | $body = $client->ensureFullCommit(); 651 | $this->assertArrayHasKey('instance_start_time', $body); 652 | $this->assertArrayHasKey('ok', $body); 653 | $this->assertEquals(true, $body['ok']); 654 | } 655 | 656 | public function test404WhenQueryAndNoDesignDocument() 657 | { 658 | $client = $this->couchClient; 659 | $query = $client->createViewQuery('foo', 'not-found'); 660 | 661 | $this->setExpectedException( 662 | 'Doctrine\CouchDB\HTTP\HTTPException', 663 | 'HTTP Error with status 404 occurred while requesting /doctrine_test_database/_design/foo/_view/not-found?. Error: not_found missing' 664 | ); 665 | 666 | $query->execute(); 667 | } 668 | 669 | public function testEncodeQueryParamsCorrectly() 670 | { 671 | $designDocPath = __DIR__.'/../../Models/CMS/_files'; 672 | 673 | $client = $this->couchClient; 674 | $designDoc = new FolderDesignDocument($designDocPath); 675 | 676 | $query = $client->createViewQuery('test-design-doc-query', 'username', $designDoc); 677 | $query->setStartKey(['foo', 'bar']); 678 | $query->setEndKey(['bar', 'baz']); 679 | $query->setStale(true); 680 | $query->setDescending(true); 681 | 682 | $result = $query->execute(); 683 | 684 | $this->assertEquals(0, $result->getTotalRows()); 685 | } 686 | } 687 | --------------------------------------------------------------------------------