diff --git a/.travis.yml b/.travis.yml index 245c4eb..f1f7bf1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,8 @@ jobs: dist: trusty - php: 5.4 dist: trusty + - php: 5.3.29 + dist: precise install: - if [[ ${TRAVIS_PHP_VERSION:0:3} < "7.1" ]]; then rm composer.lock; fi diff --git a/composer.json b/composer.json index 7c9b4ad..5e1d195 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require-dev": { "mikey179/vfsstream": "^1.6", "mockery/mockery": "1.* || 0.*", - "mustangostang/spyc": "^0.6", + "mustangostang/spyc": "dev-master#eba310c", "phpunit/phpunit": "7.* || 4.*" }, "suggest": { diff --git a/composer.lock b/composer.lock index 4fb8d1d..cad2a38 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "ac5e07039672da7f040f0ff8506869f6", + "content-hash": "857529a9f504739efe6a1d43c40bdb5d", "packages": [], "packages-dev": [ { @@ -225,7 +225,7 @@ }, { "name": "mustangostang/spyc", - "version": "0.6.3", + "version": "dev-master", "source": { "type": "git", "url": "git@github.com:mustangostang/spyc.git", @@ -1540,7 +1540,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.13.0", + "version": "v1.13.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -1687,7 +1687,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "mustangostang/spyc": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/readme.md b/readme.md index 8bf1423..8187c60 100644 --- a/readme.md +++ b/readme.md @@ -221,11 +221,13 @@ $posts = $repo->query() ## Config options - - `formatter`. See [Formats](https://github.com/jamesmoss/flywheel#formats) section of this readme. Defaults to an + - `formatter`. See [Formats](#formats) section of this readme. Defaults to an instance of `JamesMoss\Flywheel\Formatter\JSON`. - `query_class`. The name of the class that gets returned from `Repository::query()`. By default, Flywheel detects if you have APC or APCu installed and uses `CachedQuery` class if applicable, otherwise it just uses `Query`. - `document_class`. The name of the class to use when hydrating documenst from the filesystem. Must implement `JamesMoss\Flywheel\DocumentInterface`. Defaults to `JamesMoss\Flywheel\Document`. + - `indexes`. See [Indexes](#indexes) section of this readme. Defaults to an + instance of `JamesMoss\Flywheel\Formatter\JSON`. ## Formats @@ -252,12 +254,35 @@ The following formatter classes are available. **Important** If you use the `YAML` or `Markdown` formatters when using the `--no-dev` flag in Composer you'll need to manually add `mustangostang\spyc` to your `composer.json`. Flywheel tries to keep it's dependencies to a minimum. -If you write your own formatter it must implement `JamesMoss\Flywheel\Formatter\Format`. +If you write your own formatter it must implement `JamesMoss\Flywheel\Formatter\FormatInterface`. + +## Indexes + +To speed up the queries on some specific fields you can add indexes on these fields. +There are different types of index, each specialized on certain queries: + +- `HashIndex` - supports only `==` and `!=` operators. + +If a query cannot be executed only on indexes it will fallback to the default behaviour, which is opening all the files of the repository. + +**Important** Indexes maintain their own copy of the data so you won't be able to manually edit the files while keeping the consistency of the database anymore. + +```php +use JamesMoss\Flywheel\Index\HashIndex; + +$config = new Config('/path/to/writable/directory', array( + 'indexes' => { + 'fieldname' => HashIndex::class + }, +)) +``` + +If you write your own index it must implement `JamesMoss\Flywheel\Index\IndexInterface`. ## Todo - More caching around `Repository::findAll`. -- Indexing. +- More Indexes. - HHVM support. - Abstract the filesystem, something like Gaufrette or Symfony's Filesystem component? - Events system. diff --git a/src/JamesMoss/Flywheel/Config.php b/src/JamesMoss/Flywheel/Config.php index 333686d..2f01f21 100644 --- a/src/JamesMoss/Flywheel/Config.php +++ b/src/JamesMoss/Flywheel/Config.php @@ -48,12 +48,13 @@ public function getPath() * Gets a specific option from the config * * @param string $name The name of the option to return. + * @param mixed $default The default value to use. * * @return mixed The value of the option if it exists or null if it doesnt. */ - public function getOption($name) + public function getOption($name, $default = null) { - return isset($this->options[$name]) ? $this->options[$name] : null; + return isset($this->options[$name]) ? $this->options[$name] : $default; } public function hasAPC() diff --git a/src/JamesMoss/Flywheel/Formatter/JSON.php b/src/JamesMoss/Flywheel/Formatter/JSON.php index d848aaa..89d9630 100644 --- a/src/JamesMoss/Flywheel/Formatter/JSON.php +++ b/src/JamesMoss/Flywheel/Formatter/JSON.php @@ -2,8 +2,18 @@ namespace JamesMoss\Flywheel\Formatter; +defined('JSON_OBJECT_AS_ARRAY') or define('JSON_OBJECT_AS_ARRAY', 1); +defined('JSON_PRETTY_PRINT') or define('JSON_PRETTY_PRINT', 128); + class JSON implements FormatInterface { + protected $jsonOptions; + + public function __construct($jsonOptions = 0) + { + $this->jsonOptions = $jsonOptions; + } + public function getFileExtension() { return 'json'; @@ -11,13 +21,11 @@ public function getFileExtension() public function encode(array $data) { - $options = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : null; - - return json_encode($data, $options); + return json_encode($data, $this->jsonOptions); } public function decode($data) { - return json_decode($data); + return json_decode($data, $this->jsonOptions&JSON_OBJECT_AS_ARRAY); } } \ No newline at end of file diff --git a/src/JamesMoss/Flywheel/Index/HashIndex.php b/src/JamesMoss/Flywheel/Index/HashIndex.php new file mode 100644 index 0000000..50095ec --- /dev/null +++ b/src/JamesMoss/Flywheel/Index/HashIndex.php @@ -0,0 +1,104 @@ +construct($field, $repository, new JSON(JSON_OBJECT_AS_ARRAY)); + } + + /** + * @inheritdoc + */ + public function isOperatorCompatible($operator) + { + return in_array($operator, self::$operators); + } + + /** + * @inheritdoc + */ + protected function initData() + { + $this->data = array(); + } + + /** + * @inheritdoc + */ + protected function getEntries($value, $operator) + { + if (!isset($this->data[$value])) { + return array(); + } + switch ($operator) { + case '==': return array_keys($this->data[$value]); + case '!=': return $this->idsExcept($value); + default: throw new \InvalidArgumentException('Incompatible operator `'.$operator.'`.'); + } + } + + /** + * Adds an entry in the index + * + * @param string $id + * @param string $value + */ + protected function addEntry($id, $value) + { + if (!isset($this->data[$value])) { + $this->data[$value] = array(); + } + $this->data[$value][$id] = 1; + } + + /** + * Removes an entry from the index + * + * @param string $id + * @param string $value + */ + protected function removeEntry($id, $value) + { + if (!isset($this->data[$value])) { + return; + } + unset($this->data[$value][$id]); + if (count($this->data[$value]) === 0) { + unset($this->data[$value]); + } + } + + /** + * @inheritdoc + */ + protected function updateEntry($id, $new, $old) + { + if ($new !== null) { + $this->addEntry($id, $new); + } + if ($old !== null) { + $this->removeEntry($id, $old); + } + } + + protected function idsExcept($value) { + $data = $this->data; + unset($data[$value]); + return array_keys(array_reduce($data, function($prev, $val) { + return array_merge($prev, $val); + }, array())); + } +} diff --git a/src/JamesMoss/Flywheel/Index/IndexInterface.php b/src/JamesMoss/Flywheel/Index/IndexInterface.php new file mode 100644 index 0000000..9097e33 --- /dev/null +++ b/src/JamesMoss/Flywheel/Index/IndexInterface.php @@ -0,0 +1,51 @@ + a list of documents ids. + */ + public function get($value, $operator); + + /** + * Update a document in the index. + * + * @param string $id the id of this document. + * @param mixed $new the new value of this document for the indexed field. + * @param mixed $old the old value of this document for the indexed field. + * + * @return void + */ + public function update($id, $new, $old); + + /** + * Regenerate the index from the repository's documents. + * + * @return void; + */ + public function regenerate(); +} diff --git a/src/JamesMoss/Flywheel/Index/StoredIndex.php b/src/JamesMoss/Flywheel/Index/StoredIndex.php new file mode 100644 index 0000000..5320c48 --- /dev/null +++ b/src/JamesMoss/Flywheel/Index/StoredIndex.php @@ -0,0 +1,153 @@ +field = $field; + $this->formatter = $formatter == null ? new JSON() : $formatter; + $this->repository = $repository; + $this->path = $repository->addDirectory('.indexes') . DIRECTORY_SEPARATOR . "$field." . $this->formatter->getFileExtension(); + } + + /** + * @inheritdoc + */ + abstract public function __construct($field, $repository); + + /** + * @inheritdoc + */ + abstract public function isOperatorCompatible($operator); + + /** + * @inheritdoc + */ + public function get($value, $operator) + { + $this->needsData(); + return $this->getEntries($value, $operator); + } + + /** + * @inheritdoc + */ + public function update($id, $new, $old) + { + if ($new === $old) { + return; + } + $this->needsData(); + $this->updateEntry($id, $new, $old); + $this->flush(); + } + + /** + * Lazyloading data initializer. + * + * @return void + */ + protected function needsData() + { + if (isset($this->data)) { + return; + } + if (file_exists($this->path)) { + $fp = fopen($this->path, 'r'); + $contents = fread($fp, filesize($this->path)); + fclose($fp); + $this->data = $this->formatter->decode($contents); + } else { + $this->regenerate(); + } + } + + /** + * @inheritdoc + */ + public function regenerate() + { + $this->initData(); + foreach ($this->repository->findAll() as $doc) { + $docVal = $doc->getNestedProperty($this->field, $found); + if (!$found) { + continue; + } + $this->updateEntry($doc->getId(), $docVal, null); + } + $this->flush(); + } + + /** + * Write back the data on the filesystem. + * + * @return bool succeded. + */ + protected function flush() + { + $contents = $this->formatter->encode($this->data); + $fp = fopen($this->path, 'w'); + if (!flock($fp, LOCK_EX)) { + return false; + } + $result = fwrite($fp, $contents); + flock($fp, LOCK_UN); + fclose($fp); + + return $result !== false; + } + + /** + * Init the data object + * + * @return void + */ + abstract protected function initData(); + + /** + * Get entries from the index + * + * @param string $value + * @param string $operator + * + * @return array array of ids + */ + abstract protected function getEntries($value, $operator); + + /** + * Removes an entry from the index + * + * @param string $id + * @param string $value + */ + abstract protected function updateEntry($id, $new, $old); +} diff --git a/src/JamesMoss/Flywheel/Predicate.php b/src/JamesMoss/Flywheel/Predicate.php index 7efe0ec..8d75d4c 100644 --- a/src/JamesMoss/Flywheel/Predicate.php +++ b/src/JamesMoss/Flywheel/Predicate.php @@ -14,7 +14,7 @@ class Predicate const LOGICAL_OR = 'or'; protected $predicates = array(); - protected $operators = array( + protected static $operators = array( '>', '>=', '<', '<=', '==', '===', '!=', '!==', 'IN', ); @@ -63,7 +63,7 @@ protected function addPredicate($type, $field, $operator = null, $value = null) throw new \InvalidArgumentException('Field name cannot be empty.'); } - if (!in_array($operator, $this->operators)) { + if (!in_array($operator, self::$operators)) { throw new \InvalidArgumentException('Unknown operator `'.$operator.'`.'); } diff --git a/src/JamesMoss/Flywheel/QueryExecuter.php b/src/JamesMoss/Flywheel/QueryExecuter.php index 9d9e296..d3628f5 100644 --- a/src/JamesMoss/Flywheel/QueryExecuter.php +++ b/src/JamesMoss/Flywheel/QueryExecuter.php @@ -14,6 +14,7 @@ class QueryExecuter protected $predicate; protected $limit; protected $orderBy; + protected $indexes; /** * Constructor @@ -29,6 +30,7 @@ public function __construct(Repository $repo, Predicate $pred, array $limit, arr $this->predicate = $pred; $this->limit = $limit; $this->orderBy = $orderBy; + $this->indexes = $repo->getIndexes(); } /** @@ -38,10 +40,17 @@ public function __construct(Repository $repo, Predicate $pred, array $limit, arr */ public function run() { - $documents = $this->repo->findAll(); - + /** @var array $documents */ + $documents; if ($predicates = $this->predicate->getAll()) { - $documents = $this->filter($documents, $predicates); + if ($this->isFullIndex($predicates)) { + $documents = $this->findByIndex($predicates); + } else { + $documents = $this->repo->findAll(); + $documents = $this->filter($documents, $predicates); + } + } else { + $documents = $this->repo->findAll(); } if ($this->orderBy) { @@ -114,6 +123,32 @@ public function matchDocument($doc, $field, $operator, $value) return false; } + /** + * Checks if the query can be executed with indexes only. + * + * @param array $predicates the array of predicates. + * + * @return bool true if it can. + */ + protected function isFullIndex($predicates) + { + foreach ($predicates as $p) { + list($type, $field, $operator) = $p; + if (!isset($this->indexes[$field]) || !$this->indexes[$field]->isOperatorCompatible($operator)) { + return false; + } + } + return true; + } + + /** + * Filters an array of documents by the predicates. + * + * @param array $documents the array to filter. + * @param array $predicates the array of predicates. + * + * @return array the filtered array of documents. + */ protected function filter($documents, $predicates) { $result = array(); @@ -162,6 +197,50 @@ protected function filter($documents, $predicates) return $result; } + /** + * Find an array of documents from the predicates using the indexes. + * + * @param array $predicates the array of predicates. + * + * @return array the filtered array of documents. + */ + protected function findByIndex($predicates) { + $result = array(); + $ids = array(); + + $andPredicates = array_filter($predicates, function($pred) { + return $pred[0] !== Predicate::LOGICAL_OR; + }); + + $orPredicates = array_filter($predicates, function($pred) { + return $pred[0] === Predicate::LOGICAL_OR; + }); + + foreach($andPredicates as $predicate) { + if (is_array($predicate[1])) { + $ids = $this->findByIndex($predicate[1]); + } else { + list($type, $field, $operator, $value) = $predicate; + $ids = $this->indexes[$field]->get($value, $operator); + } + + $result = $ids; + } + + foreach($orPredicates as $predicate) { + if (is_array($predicate[1])) { + $ids = $this->findByIndex($predicate[1]); + } else { + list($type, $field, $operator, $value) = $predicate; + $ids = $this->indexes['$field']->get($value, $operator); + } + + $result = array_unique(array_merge($result, $ids), SORT_REGULAR); + } + + return $this->repo->findByIds($result); + } + /** * Sorts an array of documents by multiple fields if needed. * diff --git a/src/JamesMoss/Flywheel/Repository.php b/src/JamesMoss/Flywheel/Repository.php index c4463d4..244191b 100644 --- a/src/JamesMoss/Flywheel/Repository.php +++ b/src/JamesMoss/Flywheel/Repository.php @@ -2,6 +2,8 @@ namespace JamesMoss\Flywheel; +use JamesMoss\Flywheel\Index\IndexInterface; + /** * Repository * @@ -15,6 +17,8 @@ class Repository protected $formatter; protected $queryClass; protected $documentClass; + /** @var array $indexes */ + protected $indexes; /** * Constructor @@ -30,20 +34,47 @@ public function __construct($name, Config $config) $this->formatter = $config->getOption('formatter'); $this->queryClass = $config->getOption('query_class'); $this->documentClass = $config->getOption('document_class'); + $this->indexes = $config->getOption('indexes', array()); + $self = $this; + array_walk($this->indexes, function(&$class, $field) use ($self) { + if (!is_subclass_of($class, '\JamesMoss\Flywheel\Index\IndexInterface')) { + throw new \RuntimeException(sprintf('`%s` does not implement IndexInterface.', $class)); + } + $class = new $class($field, $self); + }); // Ensure the repo name is valid $this->validateName($this->name); + $this->ensureDirectory($this->path); + } - // Ensure directory exists and we can write there - if (!is_dir($this->path)) { - if (!@mkdir($this->path, 0777, true)) { - throw new \RuntimeException(sprintf('`%s` doesn\'t exist and can\'t be created.', $this->path)); + /** + * Ensure directory exists and we can write there + */ + protected function ensureDirectory($path) { + if (!is_dir($path)) { + if (!@mkdir($path, 0777, true)) { + throw new \RuntimeException(sprintf('`%s` doesn\'t exist and can\'t be created.', $path)); } - } else if (!is_writable($this->path)) { - throw new \RuntimeException(sprintf('`%s` is not writable.', $this->path)); + } else if (!is_writable($path)) { + throw new \RuntimeException(sprintf('`%s` is not writable.', $path)); } } + /** + * Adds a directory in the repository. + * + * @param string $name The name of the new directory. + * + * @return string The path of the directory. + */ + public function addDirectory($name) + { + $path = $this->path . DIRECTORY_SEPARATOR . $name; + $this->ensureDirectory($path); + return $path; + } + /** * Returns the name of this repository * @@ -64,6 +95,16 @@ public function getPath() return $this->path; } + /** + * Returns the list of indexes of this repository. + * + * @return array The list of indexes. + */ + public function getIndexes() + { + return $this->indexes; + } + /** * A factory method that initialises and returns an instance of a Query object. * @@ -79,7 +120,7 @@ public function query() /** * Returns all the documents within this repo. * - * @return array An array of Documents. + * @return array An array of Documents. */ public function findAll() { @@ -89,7 +130,10 @@ public function findAll() foreach ($files as $file) { $fp = fopen($file, 'r'); - $contents = fread($fp, filesize($file)); + $contents = null; + if(($filesize = filesize($file)) > 0) { + $contents = fread($fp, $filesize); + } fclose($fp); $data = $this->formatter->decode($contents); @@ -110,7 +154,7 @@ public function findAll() * * @param string $id The ID of the document to find * - * @return Document|boolean The document if it exists, false if not. + * @return Document|false The document if it exists, false if not. */ public function findById($id) { @@ -119,7 +163,10 @@ public function findById($id) } $fp = fopen($path, 'r'); - $contents = fread($fp, filesize($path)); + $contents = null; + if(($filesize = filesize($path)) > 0) { + $contents = fread($fp, $filesize); + } fclose($fp); $data = $this->formatter->decode($contents); @@ -136,12 +183,46 @@ public function findById($id) return $doc; } + /** + * Returns a list of documents based on their ID. + * + * @param array $ids The IDs array of document to find. + * + * @return array|false An array of Documents. + */ + public function findByIds($ids) + { + $ext = $this->formatter->getFileExtension(); + $documents = array(); + foreach ($ids as $id) { + if(!file_exists($path = $this->getPathForDocument($id))) { + return false; + } + $fp = fopen($path, 'r'); + $contents = null; + if(($filesize = filesize($path)) > 0) { + $contents = fread($fp, $filesize); + } + fclose($fp); + + $data = $this->formatter->decode($contents); + + if (null !== $data) { + $doc = new $this->documentClass((array) $data); + $doc->setId($this->getIdFromPath($path, $ext)); + + $documents[] = $doc; + } + } + return $documents; + } + /** * Store a Document in the repository. * * @param Document $document The document to store * - * @return bool True if stored, otherwise false + * @return string|false True if stored, otherwise false */ public function store(DocumentInterface $document) { @@ -155,6 +236,20 @@ public function store(DocumentInterface $document) if (!$this->validateId($id)) { throw new \Exception(sprintf('`%s` is not a valid document ID.', $id)); } + $previous = $this->findById($id); + foreach ($this->indexes as $field => $index) { + $oldFound = false; + $newFound = false; + $oldVal = $previous ? $previous->getNestedProperty($field, $oldFound) : null; + $newVal = $document->getNestedProperty($field, $newFound); + if (!$oldFound && $newFound) { + $index->update($document->getId(), $newVal, null); + } elseif ($oldFound && !$newFound) { + $index->update($document->getId(), null, $oldVal); + } elseif ($oldFound && $newFound) { + $index->update($document->getId(), $newVal, $oldVal); + } + } $path = $this->getPathForDocument($id); $data = get_object_vars($document); @@ -173,7 +268,7 @@ public function store(DocumentInterface $document) * * @param Document $document The document to store * - * @return bool True if stored, otherwise false + * @return string|false the id if stored, otherwise false */ public function update(DocumentInterface $document) { @@ -189,9 +284,14 @@ public function update(DocumentInterface $document) // If the ID has changed we need to delete the old document. if($document->getId() !== $document->getInitialId()) { - if(file_exists($oldPath)) { - unlink($oldPath); + $previous = $this->findById($document->getInitialId()); + foreach ($this->indexes as $field => $index) { + $value = $previous->getNestedProperty($field, $found); + if ($found) { + $index->update($previous->getId(), null, $value); + } } + unlink($oldPath); } return $this->store($document); @@ -200,14 +300,24 @@ public function update(DocumentInterface $document) /** * Delete a document from the repository using its ID. * - * @param mixed $id The ID of the document (or the document itself) to delete + * @param mixed $doc The ID of the document (or the document itself) to delete * * @return boolean True if deleted, false if not. */ - public function delete($id) + public function delete($doc) { - if ($id instanceof DocumentInterface) { - $id = $id->getId(); + if ($doc instanceof DocumentInterface) { + $id = $doc->getId(); + } else { + $id = $doc; + $doc = $this->findById($id); + } + foreach ($this->indexes as $field => $index) { + $found = false; + $value = $doc ? $doc->getNestedProperty($field, $found) : null; + if ($found) { + $index->update($id, null, $value); + } } $path = $this->getPathForDocument($id); @@ -338,4 +448,11 @@ protected function getIdFromPath($path, $ext) return basename($path, '.' . $ext); } + public function regenerateIndexes() + { + foreach ($this->indexes as $index) { + $index->regenerate(); + } + } + } diff --git a/test/JamesMoss/Flywheel/Formatter/JSONTest.php b/test/JamesMoss/Flywheel/Formatter/JSONTest.php index 56a132b..2bca33e 100644 --- a/test/JamesMoss/Flywheel/Formatter/JSONTest.php +++ b/test/JamesMoss/Flywheel/Formatter/JSONTest.php @@ -14,16 +14,16 @@ public function testFileExtension() public function testEncoding() { - $formatter = new JSON; + $formatter = new JSON(); + $formatterPretty = new JSON(JSON_PRETTY_PRINT); $data = array( 'name' => 'Joe', 'age' => 21, 'employed' => true, ); - $options = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : null; - - $this->assertSame(json_encode($data, $options), $formatter->encode($data)); + $this->assertSame(json_encode($data), $formatter->encode($data)); + $this->assertSame(json_encode($data, JSON_PRETTY_PRINT), $formatterPretty->encode($data)); } public function testDecoding() diff --git a/test/JamesMoss/Flywheel/Index/HashIndexTest.php b/test/JamesMoss/Flywheel/Index/HashIndexTest.php new file mode 100644 index 0000000..a1f09ad --- /dev/null +++ b/test/JamesMoss/Flywheel/Index/HashIndexTest.php @@ -0,0 +1,259 @@ + array( + 'col1' => "JamesMoss\Flywheel\Index\HashIndex", + ) + )); + $this->repo = new Repository(self::REPO_NAME, $config); + $this->index = new HashIndex('col1', $this->repo); + } + + protected function tearDown() + { + parent::tearDown(); + $this->recurseRmdir(self::REPO_PATH); + } + + public function testAddEntry() + { + $id = 'testdoc123'; + $this->index->update($id, '123', null); + $this->assertEquals(array($id), $this->index->get('123', '==')); + } + + public function testRemoveEntry() + { + $id = 'testdoc123'; + $this->index->update($id, '123', null); + $this->assertEquals(array($id), $this->index->get('123', '==')); + $this->index->update($id, null, '123'); + $this->assertEquals(array(), $this->index->get('123', '==')); + } + + public function testUpdateEntry() + { + $id = 'testdoc123'; + $this->index->update($id, '123', null); + $this->assertEquals(array($id), $this->index->get('123', '==')); + $this->index->update($id, '456', '123'); + $this->assertEquals(array(), $this->index->get('123', '==')); + $this->assertEquals(array($id), $this->index->get('456', '==')); + } + + public function testGet() + { + $n = 4; + for ($i=1; $i <= $n; $i++) { + $id = "doc$i"; + $this->index->update($id, "val$i", null); + } + $this->assertEquals(array('doc1'), $this->index->get('val1', '==')); + $this->assertEquals(array('doc1', 'doc2', 'doc4'), $this->index->get('val3', '!=')); + } + + public function testStoreDocument() + { + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals(array($id), $this->index->get('123', '==')); + } + + public function testReStoreDocumentNochange() + { + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals(array($id), $this->index->get('123', '==')); + } + + public function testReStoreDocumentAdd() + { + $doc = new Document(array( + 'col2' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + $doc->col1 = '456'; + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals(array($id), $this->index->get('456', '==')); + } + + public function testReStoreDocumentUpdate() + { + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + $doc->col1 = '456'; + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals(array(), $this->index->get('123', '==')); + $this->assertEquals(array($id), $this->index->get('456', '==')); + } + + public function testReStoreDocumentRemove() + { + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + unset($doc->col1); + $this->assertEquals($id, $this->repo->store($doc)); + $this->assertEquals(array(), $this->index->get('123', '==')); + } + + public function testUpdateDocument() + { + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $this->repo->store($doc)); + $id = 'testdoc456'; + $doc->setId($id); + $doc->col1 = '456'; + $this->assertEquals($id, $this->repo->update($doc)); + $this->assertEquals(array(), $this->index->get('123', '==')); + $this->assertEquals(array($id), $this->index->get('456', '==')); + } + + public function testExistingData() + { + $repo = new Repository(self::REPO_NAME, new Config( + self::REPO_DIR + )); + $doc = new Document(array( + 'col1' => '123', + )); + $id = 'testdoc123'; + $doc->setId($id); + $this->assertEquals($id, $repo->store($doc)); + $this->assertEquals(array($id), $this->index->get('123', '==')); + } + + public function testMultidimentionalKey() + { + $id = 'testdoc456'; + $doc = new Document(array( + 'col2' => array('4', '5', '6'), + )); + $doc->setId($id); + + $repo2 = new Repository(self::REPO_NAME, new Config( + self::REPO_DIR, array( + 'indexes' => array( + 'col2.0' => "JamesMoss\Flywheel\Index\HashIndex", + ) + ) + )); + + // test generating index from fs + $this->assertEquals($id, $this->repo->store($doc)); + $index1 = new HashIndex('col2.0', $this->repo); + $this->assertEquals(array($id), $index1->get('4', '==')); + $this->assertTrue($this->repo->delete($doc)); + + // test generating index on store document + $this->assertEquals($id, $repo2->store($doc)); + $indexes = $repo2->getIndexes(); + $index2 = $indexes['col2.0']; + $this->assertEquals(array($id), $index2->get('4', '==')); + $id = 'doc456'; + $doc->setId($id); + $this->assertEquals($id, $repo2->update($doc)); + $this->assertEquals(array($id), $index2->get('4', '==')); + $this->assertTrue($repo2->delete($id)); + $this->assertEquals(array(), $index2->get('4', '==')); + } + + public function testInconsitentData() + { + $doc1 = new Document(array('col1' => '1')); + $doc1->setId('doc1'); + $doc2 = new Document(array('col2' => '2')); + $doc2->setId('doc2'); + $doc3 = new Document(array('col1' => '')); + $doc3->setId('doc3'); + $doc4 = new Document(array('col2' => '4')); + $doc4->setId('doc4'); + $doc5 = new Document(array('col1' => '')); + $doc5->setId('doc5'); + + $repo2 = new Repository(self::REPO_NAME, new Config(self::REPO_DIR, array())); + $query11 = $this->repo->query()->where('col1', '==', 1)->orderBy('__id'); + $query12 = $this->repo->query()->where('col1', '!=', 1)->orderBy('__id'); + $query21 = $repo2->query()->where('col1', '==', 1)->orderBy('__id'); + $query22 = $repo2->query()->where('col1', '!=', 1)->orderBy('__id'); + + // test generating index from document files + $this->assertEquals('doc1', $repo2->store($doc1)); + $this->assertEquals('doc2', $repo2->store($doc2)); + $this->assertEquals('doc3', $repo2->store($doc3)); + $this->assertEquals($query21->execute(), $query11->execute()); + $this->assertEquals($query22->execute(), $query12->execute()); + + // test generating index on store document + $this->assertEquals('doc4', $this->repo->store($doc4)); + $this->assertEquals('doc4', $repo2->store($doc4)); + $this->assertEquals('doc5', $this->repo->store($doc5)); + $this->assertEquals('doc5', $repo2->store($doc5)); + $this->assertEquals($query21->execute(), $query11->execute()); + $this->assertEquals($query22->execute(), $query12->execute()); + } + + public function testRegenerate() + { + $id = 'doc1'; + $doc = new Document(array('col1' => 'val1')); + $doc->setId($id); + $this->repo->store($doc); + $this->assertEquals(array($id), $this->index->get('val1', '==')); + $formatter = new JSON(); + file_put_contents($this->repo->getPathForDocument($id), $formatter->encode(array('col1' => 'val2'))); + $this->index->regenerate(); + $this->assertEquals(array(), $this->index->get('val1', '==')); + $this->assertEquals(array($id), $this->index->get('val2', '==')); + } + +} \ No newline at end of file diff --git a/test/JamesMoss/Flywheel/QueryExecuterTest.php b/test/JamesMoss/Flywheel/QueryExecuterTest.php index 19d6e1b..6941ba0 100644 --- a/test/JamesMoss/Flywheel/QueryExecuterTest.php +++ b/test/JamesMoss/Flywheel/QueryExecuterTest.php @@ -85,15 +85,15 @@ public function testSubPredicates() ->orWhere('language.0', '==', 'English'); }); - $qe = new QueryExecuter($this->getRepo('countries'), $pred, array(), array()); + $qe = new QueryExecuter($this->getRepo('countries'), $pred, array(), array('name ASC')); $result = $qe->run(); $this->assertEquals(3, $result->total()); - $this->assertEquals('Vatican City', $result->first()->name); + $this->assertEquals('Gibraltar', $result->first()->name); $this->assertEquals('San Marino', $result[1]->name); - $this->assertEquals('Gibraltar', $result[2]->name); + $this->assertEquals('Vatican City', $result[2]->name); } public function testInOperator() @@ -103,17 +103,15 @@ public function testInOperator() ->andWhere('population', '<', 40000) ->andWhere('language.0', 'IN', array('Italian', 'English')); - $qe = new QueryExecuter($this->getRepo('countries'), $pred, array(), array()); + $qe = new QueryExecuter($this->getRepo('countries'), $pred, array(), array('name ASC')); $result = $qe->run(); $this->assertEquals(3, $result->total()); - $this->assertEquals('Vatican City', $result->first()->name); - // The two following assertions doesn't work on PHP 5.4 and 5.5 - // probably because these versions need mockery 0.* instead of 1.* - // $this->assertEquals('San Marino', $result[1]->name); - // $this->assertEquals('Gibraltar', $result[2]->name); + $this->assertEquals('Gibraltar', $result->first()->name); + $this->assertEquals('San Marino', $result[1]->name); + $this->assertEquals('Vatican City', $result[2]->name); } public function testSimpleOrdering() @@ -193,11 +191,44 @@ public function testOrderingById() $this->assertEquals('Djibouti', $result[2]->name); } + /** + * @dataProvider hashIndexOperatorsProvider + */ + public function testFindByIndex($operator) + { + $pred = $this->getPredicate() + ->where('region', $operator, 'Europe'); + $options = array( + 'indexes' => array( + 'region' => '\JamesMoss\Flywheel\Index\HashIndex' + ) + ); + $n = 5; + + $qe = new QueryExecuter($this->getRepo('countries', $options), $pred, array(), array()); + $start = microtime(true); + for ($i=0; $i < $n; $i++) { + $withIndex = $qe->run(); + } + $timeWithIndex = microtime(true) - $start; + + $qe = new QueryExecuter($this->getRepo('countries'), $pred, array(), array()); + $start = microtime(true); + for ($i=0; $i < $n; $i++) { + $withoutIndex = $qe->run(); + } + $timeWithoutIndex = microtime(true) - $start; - protected function getRepo($repoName) + $this->assertSameSize($withoutIndex, $withIndex); + $this->assertEqualsUnordered(get_object_vars($withoutIndex), get_object_vars($withIndex)); + $this->assertLessThan($timeWithoutIndex, $timeWithIndex); + } + + + protected function getRepo($repoName, $options = array()) { $path = __DIR__ . '/fixtures/datastore/querytest'; - $config = new Config($path . '/'); + $config = new Config($path . '/', $options); return new Repository($repoName, $config); } @@ -206,4 +237,12 @@ protected function getPredicate() { return new Predicate(); } + + public function hashIndexOperatorsProvider() + { + return array( + array('=='), + array('!='), + ); + } } diff --git a/test/JamesMoss/Flywheel/RepositoryTest.php b/test/JamesMoss/Flywheel/RepositoryTest.php index 164a610..49c192a 100644 --- a/test/JamesMoss/Flywheel/RepositoryTest.php +++ b/test/JamesMoss/Flywheel/RepositoryTest.php @@ -6,6 +6,10 @@ class RespositoryTest extends TestBase { + const REPO_DIR = '/tmp/flywheel'; + const REPO_NAME = '_pages'; + const REPO_PATH = '/tmp/flywheel/_pages/'; + /** @var Repository $repo */ private $repo; @@ -13,12 +17,17 @@ class RespositoryTest extends TestBase protected function setUp() { parent::setUp(); - - if (!is_dir('/tmp/flywheel')) { - mkdir('/tmp/flywheel'); + if (!is_dir(self::REPO_DIR)) { + mkdir(self::REPO_DIR); } - $config = new Config('/tmp/flywheel'); - $this->repo = new Repository('_pages', $config); + $config = new Config(self::REPO_DIR); + $this->repo = new Repository(self::REPO_NAME, $config); + } + + protected function tearDown() + { + parent::tearDown(); + $this->recurseRmdir(self::REPO_PATH); } /** @@ -26,9 +35,10 @@ protected function setUp() */ public function testValidRepoName($name) { - $config = new Config('/tmp'); + $config = new Config(self::REPO_DIR); $repo = new Repository($name, $config); $this->assertSame($name, $repo->getName()); + $this->recurseRmdir($repo->getPath()); } /** @@ -37,7 +47,7 @@ public function testValidRepoName($name) */ public function testInvalidRepoName($name) { - $config = new Config('/tmp'); + $config = new Config(self::REPO_DIR); new Repository($name, $config); } @@ -107,7 +117,7 @@ public function testStoringDocuments() $repo->store($document); $name = $i . '.json'; - $this->assertSame($data, (array) json_decode(file_get_contents('/tmp/flywheel/_pages/' . $name))); + $this->assertSame($data, (array) json_decode(file_get_contents(self::REPO_PATH . $name))); } } @@ -116,7 +126,7 @@ public function testDeletingDocuments() $repo = $this->repo; $id = 'delete_test'; $name = $id . '.json'; - $path = '/tmp/flywheel/_pages/' . $name; + $path = self::REPO_PATH . $name; file_put_contents($path, ''); @@ -138,7 +148,7 @@ public function testRenamingDocumentChangesDocumentID() $repo->store($doc); - rename('/tmp/flywheel/_pages/testdoc123.json', '/tmp/flywheel/_pages/newname.json'); + rename(self::REPO_PATH . 'testdoc123.json', self::REPO_PATH . 'newname.json'); foreach ($repo->findAll() as $document) { if ('newname' === $document->getId()) { @@ -160,12 +170,32 @@ public function testChangingDocumentIDChangesFilename() $doc->setId('test1234'); $repo->store($doc); - $this->assertTrue(file_exists('/tmp/flywheel/_pages/test1234.json')); + $this->assertTrue(file_exists(self::REPO_PATH . 'test1234.json')); $doc->setId('9876test'); $repo->update($doc); - $this->assertFalse(file_exists('/tmp/flywheel/_pages/test1234.json')); + $this->assertFalse(file_exists(self::REPO_PATH . 'test1234.json')); + } + + public function testFindByIds() + { + for ($i=0; $i < 10; $i++) { + $doc = new Document(array( + 'test' => $i, + )); + $doc->setId("doc$i"); + $this->repo->store($doc); + } + $docs = $this->repo->findByIds(array('doc1', 'doc3', 'doc4')); + $this->assertCount(3, $docs); + $this->assertEquals(1, $docs[0]->test); + $this->assertEquals(3, $docs[1]->test); + $this->assertEquals(4, $docs[2]->test); + $docs = $this->repo->findByIds(array('doc1', 'DOC3', 'doc4')); + $this->assertFalse($docs); + $docs = $this->repo->findByIds(array()); + $this->assertCount(0, $docs); } // public function testLockingOnWrite() diff --git a/test/JamesMoss/Flywheel/TestBase.php b/test/JamesMoss/Flywheel/TestBase.php index c4bb75b..0505df9 100644 --- a/test/JamesMoss/Flywheel/TestBase.php +++ b/test/JamesMoss/Flywheel/TestBase.php @@ -7,5 +7,46 @@ class TestBase extends \PHPUnit\Framework\TestCase public function normalizeLineendings($content) { return str_replace("\r\n", "\n", $content); - } -} \ No newline at end of file + } + + public function recurseRmdir($dir) + { + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->recurseRmdir("$dir/$file") : unlink("$dir/$file"); + } + return rmdir($dir); + } + + private function arraysEqualsUnordered($a, $b) + { + // if the indexes don't match, return immediately + if (count(array_diff_assoc($a, $b))) { + return false; + } + // we know that the indexes, but maybe not values, match. + // compare the values between the two arrays + foreach ($a as $k => $v) { + if ($v !== $b[$k]) { + return false; + } + } + // we have identical indexes, and no unequal values + return true; + } + + /** + * Determine if two associative arrays are similar + * + * Both arrays must have the same indexes with identical values + * without respect to key ordering + * + * @param array $expected + * @param array $actual + * @return bool + */ + public function assertEqualsUnordered($expected, $actual) + { + $this->assertTrue($this->arraysEqualsUnordered($expected, $actual)); + } +} diff --git a/test/JamesMoss/Flywheel/fixtures/datastore/querytest/countries/.indexes/region.json b/test/JamesMoss/Flywheel/fixtures/datastore/querytest/countries/.indexes/region.json new file mode 100644 index 0000000..b521fa8 --- /dev/null +++ b/test/JamesMoss/Flywheel/fixtures/datastore/querytest/countries/.indexes/region.json @@ -0,0 +1 @@ +{"Europe":{"Isle of Man_76733f338bb21fd91ee8304d05accb9163ee6e52":1,"Montenegro_479fb34bdbf4aba90d03cca83f64ad4e912cc854":1,"Hungary_f14e46ce7d094f9326167acc499698128651be85":1,"Bosnia and Herzegovina_62ac59b7255ce5ff9fee8ac49157bdd9bc4445e2":1,"Germany_17d53e0e6a68acdf80b78d4f9d868c8736db2cec":1,"Faroe Islands_1082f89115626b9c431f2176f832b3a7cc60fb2c":1,"Monaco_35ab1c3de6dac148401654e1ebf5dd935ccefa14":1,"Romania_d6b897fd145a64fbad36ff7cb1c47a00dcbbe9b6":1,"Netherlands_fb61c8a8eba24117016597fc7618f38821b16e8f":1,"Republic of Kosovo_338e718d14c93382811119b87ed77b25aaed8a98":1,"Russia_6754fe3cd8310d20ed04b8c7b66abebcdb16d88d":1,"France_e3772ac4b4db87b4a8dbfa59ef43cd1a8ad29515":1,"Croatia_d7e0453bb4af87006533f4d77ad9546dac533db8":1,"Estonia_f0a96da3f86a334cbe8d569110d6545277973859":1,"Vatican City_136f5db5de74430b7c1e66fcd4ef0b6a70ace276":1,"Greece_4902a456caa9a4eab463ce526c9df0f6180be184":1,"Belarus_027a12c2fc8568e8b70b07ff536faf288a013670":1,"Andorra_9d3bd1fb52785a7baeb03b437e7b2ecba54ef34f":1,"Macedonia_41349e9557e537855ae0f63cc43918fa13a561d3":1,"Guernsey_180c97abdb80833b91cb32903dced0e8f8b49562":1,"San Marino_87fd3befcce67042618f49ec496c2b09a49459fa":1,"Czech Republic_1fef42909247a6c230eeef66277f2b5e0e8f274e":1,"Svalbard and Jan Mayen_ac99404d12d60aa0c46631720dd7afa750d0e909":1,"Jersey_ff4636d0caf61d70e7dfa437d80aab75e921e76c":1,"Norway_988455e67df7cd81d090ea4bacdc05f39fb7caa5":1,"Portugal_a495190bcc2ef4116725616d321016a7def5bd8f":1,"Iceland_b3c92eecf0aa1905086059d9f6d3261d8fb19657":1,"Moldova_9791bc4d7273587d61fef79841bd342381f5e321":1,"United Kingdom_9769121f10f77079b27eb08e9ffa488cbcc37ed0":1,"Switzerland_77dcd849e550afec3c83d38fcc8cbc72c058f4db":1,"Belgium_5cb4c9d828175ed3931ec52305b32f47173a8e04":1,"Latvia_c5f5bb3b350774d7cda57104c55fb6c82b7ae7d9":1,"Spain_20a8df9b760336178fca425339ec1c7e542a2463":1,"Finland_c909b138eba89ecfbd86df4c9d170ac78d4a3820":1,"Austria_593905b31972f6ffe58325abf98595caf4ebf458":1,"Liechtenstein_b0ddce0f54c916c106117e280aead4f9c0cbf1df":1,"Italy_ad79ef0f076d3a686ab9738925f4dd2c7e69d7d1":1,"Lithuania_74a788cee27d549015a0786732c662e05cdd7567":1,"Albania_79b9d273ac6d2488109d1ea43e2bdb7977bd2b28":1,"Malta_1a591a3e91fcb7a47f2c08e9e2e117f39af22078":1,"Denmark_89da124e04dfe1ad9946cd37d91a119e1d028898":1,"Luxembourg_5076721c4060feeb69bd2c3dd9bdce115d5c62f3":1,"Serbia_6d31bf00d7eddc6a617a6b16699f8ba91794e2fd":1,"Ukraine_c951ec00f123510a00d1e3d9539b11b4631d4096":1,"Slovenia_d1aa0503612aa4168939b77b59ca74532a11951a":1,"Sweden_72ddd2b619af6d6a73febf80f7fcad22495498cd":1,"A\u030aland Islands_065154080d2f7539638e616e64cfcdb36c0577a1":1,"Ireland_eb2131ece0efe78ee8bb1ae98af6099114a8df09":1,"Slovakia_b6c149c3e00467fba347629a63ed02fed098d061":1,"Poland_5ff03b7273b1808e5ba852e230991bbf07da703c":1,"Bulgaria_5c77726358c5daf98ad9cdccd0882bca0f718b88":1,"Gibraltar_e51335897c0ef4bb952693d4166902146a1bc812":1},"Americas":{"Chile_349507e41dd8c71c10c9df6d2444b5e64a285691":1,"Saint Pierre and Miquelon_70e53283edb6d53accd21aa7731dfe1f66246b84":1,"Jamaica_5eedd6a16ab862cb5d6b2e194e0bdf8b0161d89a":1,"Argentina_354bf98925838ca68611b950e2a37ebd11c21640":1,"Uruguay_66b98924b384b40aea844bf0fe399d5d3832388e":1,"Bonaire_fd71a444cd1566324317cbc5204af645857b5b72":1,"Saint Kitts and Nevis_3ad2dd829664a86f29aec0770e244c1717c79255":1,"Costa Rica_1bf429f94068620b112aca3888b58aecbc2eadee":1,"Suriname_b17fc6f05c078b63528a6d19c2abe0917e1e8a1b":1,"Saint Vincent and the Grenadines_3fa9b2dd85d217dd4f6407fbb779928701f9e184":1,"French Guiana_07090aa7d613105dad9bd80005cd91b8da1c4cb2":1,"Montserrat_35f005157cfd133875b06f037eeeff3b7494456c":1,"Trinidad and Tobago_4d206a8103d3d20ac685314dbba1c88f6cfedd85":1,"South Georgia_618cd353b39276e0dd2215fabbbbb4d21803a69c":1,"Paraguay_71a45296474d608f35d4b49f3b10384fc738e16f":1,"Bahamas_1f797564f7843d36ebe5e841e5bd39c98157e22b":1,"Saint Lucia_673b1cf863c8e2d10109731b098b19ba81aec9fb":1,"United States Virgin Islands_65f5cb3322400cfd62ba3c8bcffbd7a8d71d0f32":1,"Turks and Caicos Islands_611bc125b7ad66ad9d5103c6bfd968a775763e6c":1,"Haiti_a4842f0234d7270b757b60b6f17a1b9f4d560dad":1,"Falkland Islands_11499959510cbab21e5241a8a6099a3d545b5227":1,"Puerto Rico_fe23c52dc2441843aa073092188089600dad336c":1,"Aruba_f3a826101b9a25f3573cb0f1a7505587de8c65c3":1,"Honduras_5aa588714ab4cbfd615d238fd9778c3a14ca4ba8":1,"Bermuda_027ed37f00fb38adb089a91f2d52c7b931949168":1,"Anguilla_5722849f2ccb586368a07473e71e8df1bae7a221":1,"Curac\u0327ao_9461dd94ebb7643ea4fbbed9dc781497e05a6e7e":1,"Panama_1e36b31e788231fba03577144de1d23b04a5d324":1,"United States_768685ca582abd0af2fbb57ca37752aa98c9372b":1,"Peru_36c57243155b80cf350c59764354b20dde157333":1,"Nicaragua_4812648c8a890c1818305642f6a01fa134b34401":1,"Cuba_c484b137e2d18229e4e7e677f1f8cfce6a0ab819":1,"United States Minor Outlying Islands_ede34a33eb69cc5980a8c2353752463771b7060e":1,"Guadeloupe_f0f524ff3b9cf7a30a94a59841b9c0d2fd858b56":1,"British Virgin Islands_1fc1b5b29cefd14d32c7e252ca38633927f0c3ee":1,"Dominican Republic_8a4bf12e17b2be590b12721ef8d5d0248698b5b6":1,"Colombia_2f737399606486655401cb27b066f4658424766d":1,"Saint Martin_854ccc29cc22ef957d1e5ce961f30c1e92677d9c":1,"Antigua and Barbuda_530670dca74039e859436ac4734296b861556efd":1,"Belize_42ab0c94a1e3bd6175a15dba215ccf5f10e861e5":1,"Ecuador_09f199d25132204e99bfb1a89916de24494f19bb":1,"Dominica_bc1cd4f07d828493332f9c95c491d6fb338afd40":1,"Greenland_1ba0cfa550295f8b2c5fa44b8236639adc825cf9":1,"Mexico_41937b20fbe8c71d9c6c3346aff43c001aa25e33":1,"El Salvador_259a6e935b848823a0c0b76aced2803060b4bd0c":1,"Guyana_bc88a24030a15bde613c8749ed93d30e0a81fcc2":1,"Sint Maarten_ad7c3cc73f9cd1c049d3208aac984c49953b94b2":1,"Guatemala_11760e1aac4396e10d315e93ad3df3e99204dc5e":1,"Grenada_dbf2a2da6458b242407285e7f1483aeb6cfee9fd":1,"Martinique_66f1a98a952d50fc3164cde58265772e2ab821c2":1,"Bolivia_a001af75ee89582f31cb4db6d3dd0b4766c80050":1,"Barbados_93409af2f208e7545f0f26996e048113edd88652":1,"Venezuela_9d4ac43d5e24e3d01e448ce01a277ba5971e60f6":1,"Canada_cd6a7b8768528485a0dbcd459185091e80dc28ad":1,"Saint Barthe\u0301lemy_f48e864508243ec01f6ac7ff781f13cd5bde0a0f":1,"Cayman Islands_e9e21c35ab345ef790305554368ebf78279482f2":1,"Brazil_37497aa5a2272c49714aee1b07e8edf973a95f59":1},"Africa":{"Guinea_b47b54fd3a460f43ebbcabe5b10c189176f25f25":1,"Gabon_a06dcd71328fd2b44fa7f84012096e8f318e4b48":1,"Swaziland_c985df0809c3183c074ef84ac9e902d70ff81992":1,"Niger_6687e1896dd857dc1587782149127ff073a95696":1,"Lesotho_93b12bf57f18c13c9ad2f55e33a8e3fd786fc394":1,"Liberia_1ed5dd9d833f675b7509886681e2164d842f8dad":1,"Libya_55949d4c16632f1c275d7684a379b8f1717b3904":1,"Ethiopia_3d91f7631ba813c13a08a208f1255b2e96fb03d0":1,"Democratic Republic of the Congo_f39d28021812d3e91f4c64c9ac055a20d5bcfd30":1,"Morocco_32e087f099be121c1211285a1700ff83542193f7":1,"Cameroon_73a7ddd505f2fc2cead1522e54a794328f228c44":1,"Cape Verde_8e40809e474f3e0705ddef056618ce3e5043522a":1,"Botswana_180c89bf50c8b29deb45e49d9dac62fcd5c8bedb":1,"Seychelles_3d2d7b1c3400e9222f97a999decf652a6c34bdc5":1,"Co\u0302te d'Ivoire_b2182992301690448db9754c5493729921c28056":1,"Madagascar_f92bcb6a06d2ec7c0af7c8a338f131bf887c64a0":1,"Re\u0301union_8e7e261a1ff0a9bbf184ec58b7db9d014d425e65":1,"British Indian Ocean Territory_9bb835f4f91b31fae3286529520eecae620d718c":1,"Mayotte_dcc64425eed0e625072cc41ac60a88d4b3d50086":1,"Egypt_1c39abf68e93e438ae5dca946e2d6a986cccd3a9":1,"Sa\u0303o Tome\u0301 and Pri\u0301ncipe_655191a80835f37342e045bd71b73188430d3b39":1,"Central African Republic_dba86789b53e54df0477a589a57298893cd87502":1,"South Sudan_6981f4026cf83935b630141d375a9455fc18acd7":1,"Republic of the Congo_b78ae043a4412720001cad4a83c176d09da0437f":1,"Malawi_0ce65b26dcd3c08e1b329d9efbb6bbbced426f31":1,"Eritrea_18740af5bcec9573bd3c059e0fc6570353097aab":1,"Sudan_1193ba31f109ecfdfab76f13cc2b5e44479d5603":1,"Algeria_bd6acc8626d118aea60331ce33bf000c9d7d1cee":1,"Zimbabwe_5922f5ceeff38bcfdb993efd6cfd5c472f827fa9":1,"Ghana_317dbac90743c3e5e82b2ddf122cd076a2226a92":1,"Nigeria_9742d008c420ec9d44b1794c03b9701c477f93ff":1,"South Africa_35fda17ff05f63e9061208c2dd2aaaf98790e921":1,"Burkina Faso_e71b56d92347386fa76239ce33f67aaa2de52207":1,"Gambia_7c39974f44b2b12933c66a9eba3fe33c8d0805b6":1,"Saint Helena_03bdca149f934311777f23c15054c37a49e3289b":1,"Somalia_4dfdf195ecb76a3fa83788deca6fd3b289dd568f":1,"Equatorial Guinea_80eaf3087b0b041473c6c223f1e09197b649362e":1,"Tunisia_edf404d0db32652d41a29b428a804405e1d73a9e":1,"Uganda_e92904bce8026b3c1f8828b0ce882e6b081c7fb6":1,"Namibia_bfe79debcedda1c1e11f7836c0fc13ad22dffca7":1,"Kenya_a84f56f2e6a77ecb4b2f89344446dd3ff91b87c4":1,"Mozambique_a40a9be00a9ae956acd67011c0d96758916f40ea":1,"Mauritius_26160d23fb07cf8d5dae186eba322e9fc8e27bb4":1,"Senegal_d8973b8edfe5e3211044e39d5452523b5d69cba9":1,"Togo_30949d5f4a69766caa7abe5e6fd9993090b1b6b3":1,"Guinea-Bissau_b07cb9a2b24832a8197cd3dfa67d1d6adfb0cfbb":1,"Western Sahara_fab1a52391201253134c8cc0b956613cad675611":1,"Djibouti_60a8b0c6de6f6abe5999959a5c7352750116fb9c":1,"Mali_daa6a489dbca7a13c480aaa1d0c344957590fdb7":1,"Zambia_dcf25ed56c9566181e0f2d48d8854c04c4ca6b37":1,"Rwanda_7266a1da7e3a6739b245ddfe74b0b682f7da63f8":1,"Burundi_4617585b8749a71bbb21237cab6c2dc9cbe3b86f":1,"Benin_373616e39fb47d9a1a4e87dfd4ea968037435f14":1,"Chad_6c6b1b2b3ecc0e6900000dabf4faae6f8df5ffd1":1,"Angola_a42522a0cdd6e41a1379e6c95d08a9c46a17249c":1,"Comoros_e5bb59a2731998cae2070f6fd4f2e075fd61146f":1,"Tanzania_7e380be8dc28d72571144716e95e598c986bc4d6":1,"Sierra Leone_50df2d658581499ea96e34b53f6280fd7754bd1f":1,"Mauritania_85fa355bdb4f46fa53a20a441623d53d686d4036":1},"Asia":{"Armenia_5f4599daa3415f788c1afc3db145f01b4bd2b438":1,"Tajikistan_279c771ae60c33fc34bde4627bb56ee8eecca33a":1,"Bahrain_3ae11c725c30009d4d3418bc6b30789feed78322":1,"Lebanon_5caa7f811b5aff6eb9993f309c4c045785ee67ec":1,"Malaysia_ff3ea3bec182358766650a6fd2872d9221f7e6cc":1,"Timor-Leste_2d79f6dbae283f57f4092d5370a518c3ef5caec1":1,"Iraq_1aed9ecc6e1be8eaaaddc5ffb6ba3ed84a8b1ae6":1,"North Korea_765b46e79beff128df05b121d344cc7ad23dbac5":1,"Laos_7998be8d446d668c99abca446cb3bd79fce08d2b":1,"Nepal_0e1d589f58b71e2b7d5d8068156d160146820407":1,"Japan_fcf29f6cad3232704b33e962ef5194fad3b6817b":1,"Saudi Arabia_6f49c31192f5681e1053a1d699956998c5d7e97d":1,"Yemen_ac33d641f4c9d9de5b29bb95e7f909066cfca512":1,"Oman_c14c36dffca61b07410f5e3630ba5d99c60ab5d5":1,"Afghanistan_c69153687791fb52c12ce7cca2f4d03a65d9abf8":1,"Myanmar_928b7c48a60ad93b81bc3bee9d274c5f2aed9ad3":1,"Uzbekistan_db800e86e9f303e72e1102efb21d459512902d37":1,"Israel_4c197dfd67f1ed79d11a8b0218cc368bfcce6ccb":1,"India_967ce367d89dccc133d71049f1197d29561b3726":1,"Brunei_130e4a34f1b807a8f3bd24b204c06ec4de4010da":1,"Azerbaijan_213598a7e92217bee3a758f3c69aea09ed940c0e":1,"Bhutan_bb2254a806f43df24753ca390143e2ca8c1e4e80":1,"Qatar_83ef3e6cee83cb88a39814638a94fa1ad33e65a5":1,"United Arab Emirates_d115b8d5acb8386b8012aaf4cbb3812cacd97c8e":1,"Bangladesh_fa6c3752cd00f7f1277fd7e5604ab8d2edaf26b8":1,"Kyrgyzstan_c78791a3f0e109d34ba4eac2afa35ec011439ae3":1,"Palestine_05a15c18a2038b35edcdb52e92f002c65d4fcf32":1,"Syria_3ed104337eb1dfb750107978743cbf25fbe2c2b5":1,"Thailand_a2b7c120c93a01e67dcd4d984d2a781cde2c46df":1,"Cambodia_314ccd964ef6a8e68ac5f9bb89f751a1c2196c56":1,"Turkmenistan_1f8dc1a6076e90fb31efa48a19e2d220b647c81f":1,"Sri Lanka_197042c4de91dae8aeafa9a52dc8c3a59aa41dc0":1,"Cyprus_852addab901cbc5699d190285a009d7a7035fb57":1,"China_d2eaf2aa1512d6596e0a5bae633537c6b8e779a3":1,"Mongolia_f54da380bade5d43098fd1f338cb8257523c508b":1,"Kazakhstan_2f36b6bb1852c24392fb3ee9a2879da24eb0750d":1,"Iran_889224e3fca24a6ab17d01fe47a45bc82244e938":1,"South Korea_04653e650280742f94a5a74ad6530ee09c2dc2be":1,"Vietnam_681101d8f9fa4e5e1fbe8ac5a2c05504c6c4875a":1,"Indonesia_35536a41b209715d9e3ad440431fef2672f20bbe":1,"Singapore_20c0b7bdab70ca2cc9c844a0d74a3af0bbf41c3e":1,"Kuwait_93295b0c76b900da760a8e0f2e9a29a1ba4b0f4c":1,"Georgia_9113c6c0c1f9cb53e3543b53136ba30c51018373":1,"Macao_918c53a2d6129e9e4f42191b60fa11886bd9fa50":1,"Taiwan_094d515b3608fefc6759a36412cee467437417a5":1,"Pakistan_82d220df17cebf5ce4897d780c354dd5e925c209":1,"Philippines_8067364d44f5e37baba7e13ba124e934df410e2a":1,"Jordan_674027e17b0ed64e76cde2005cb8e76fb4cd671a":1,"Turkey_d7153e6702b4ea48c7c0d01affdef0e1b39fd6dc":1,"Maldives_213cb204509284ff2aedeca9290b70a6da307eab":1,"Hong Kong_2f488ffc82ccc67a1616e39fdfc537297f1646c5":1},"Oceania":{"Palau_e5e0b68adfacb66979505bedf6d424070c536eb6":1,"Fiji_bbb7ef7f2ec9557d0895c9da1c5cddd50d15049e":1,"Papua New Guinea_fbaa3c3e9e1bbf9fe813f2d4c038b295ef046512":1,"Cook Islands_4bacfe2535ecbceb9a04a1b85c3a2e0bc53c64d6":1,"Christmas Island_c6dfa97a611dbc45bfdc51c69a762488964e6361":1,"French Polynesia_40f39cdcd3acd771d31e4269e54caeff9b3b5edf":1,"Pitcairn Islands_0311d743a395837f9490fa8a89013d4b047b366e":1,"Tokelau_a08c1ca2fca39f1ea2de01c2de009926d07afb4d":1,"Northern Mariana Islands_28a117f173634e3c574231e0cfb011aabfd2bba8":1,"Guam_f8aa3a934ee38a6c86d1b092624a9c385267d927":1,"Kiribati_3f57adf2c06cecd095ff83ab72787889961bbe87":1,"Marshall Islands_bb130626b42c3cce860334424aef9a144f8231f2":1,"Tuvalu_9a2248f7c4de37c13c8e2688356857b589f669b4":1,"American Samoa_ca0b36fec74bc61226adce2b5ce0e8ef6fdca179":1,"Nauru_f648c72a69c3a9b157c3603ebd4a75141a833e46":1,"Tonga_e8a1234b8442f09d8965e48d93a16e10fa398baa":1,"Australia_ceafb51e2b0783d53dd620019dff3aa66708a26f":1,"Wallis and Futuna_a187509aa2afa6ef9b1abbaed04d4bb5b6124ffb":1,"Norfolk Island_fedb20736102194ca53af14fae03929409c27b9c":1,"New Zealand_a2238e91907fa2436a6a50e0fd8cf97ab60ec508":1,"Niue_c635e30148c3a013261cd3147f9ff8c70b2f9fea":1,"Solomon Islands_ecae78abec522d0fa00ddb3666aa9db9baf0265f":1,"Samoa_f5680604ea9e0abbe3833339ee5a793c2c02551d":1,"Micronesia_5a7d7bc91620c2307f0ad338a4b7d7929bb8af8e":1,"Cocos (Keeling) Islands_d2dd2d43fef0a1aa180b01e58e8a9c32d7ac165a":1,"Vanuatu_d16ad2dcc6af8252a554ea5f1722abc518dfa3b1":1,"New Caledonia_b9967e88983d851428fa745aa72bb8b4e0bec2da":1},"":{"Bouvet Island_290125ef9fc28ca6df6137e0523169786f3ecfea":1,"Antarctica_00f33fc530d3e011ee6ab56f206622e221888971":1,"French Southern and Antarctic Lands_44e46ba30eabfe96165ac52eb27f1318dd7396e4":1,"Heard Island and McDonald Islands_130447083d0bf6b6914b952751355ad34949129b":1}} \ No newline at end of file