Skip to content

Commit

Permalink
Support unique indices in InMemoryDocumentStore
Browse files Browse the repository at this point in the history
  • Loading branch information
codeliner committed Nov 12, 2019
1 parent 3bebe19 commit 308ebf1
Show file tree
Hide file tree
Showing 11 changed files with 532 additions and 6 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# php-document-store

Event Engine PHP Document Store Contract

## Testing

This package includes an in-memory implementation of the `DocumentStore` interface which is useful for tests.
To be able to test the in-memory implementation in isolation we have to copy some classes from `event-engine/persistence` into the test namespace of this repo.
The implementation depends on classes from that other package, but we cannot pull it with composer due to circular dependencies.
We'll solve the issue in the future by moving the in-memory implementation to `event-engine/persistence`, but for now backwards compatibility is more important.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
},
"autoload-dev": {
"psr-4": {
"EventEngineTest\\DocumentStore\\": "tests/"
"EventEngineTest\\DocumentStore\\": "tests/",
"EventEngine\\Persistence\\": "tests/Persistence"
}
},
"prefer-stable": true,
Expand Down
148 changes: 143 additions & 5 deletions src/InMemoryDocumentStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Codeliner\ArrayReader\ArrayReader;
use EventEngine\DocumentStore\Exception\RuntimeException;
use EventEngine\DocumentStore\Exception\UnknownCollection;
use EventEngine\DocumentStore\Filter\AndFilter;
use EventEngine\DocumentStore\Filter\EqFilter;
use EventEngine\DocumentStore\Filter\Filter;
use EventEngine\DocumentStore\OrderBy\AndOrder;
use EventEngine\DocumentStore\OrderBy\Asc;
Expand Down Expand Up @@ -68,6 +70,7 @@ public function hasCollection(string $collectionName): bool
public function addCollection(string $collectionName, Index ...$indices): void
{
$this->inMemoryConnection['documents'][$collectionName] = [];
$this->inMemoryConnection['documentIndices'][$collectionName] = $indices;
}

/**
Expand All @@ -78,12 +81,20 @@ public function dropCollection(string $collectionName): void
{
if ($this->hasCollection($collectionName)) {
unset($this->inMemoryConnection['documents'][$collectionName]);
unset($this->inMemoryConnection['documentIndices'][$collectionName]);
}
}

public function hasCollectionIndex(string $collectionName, string $indexName): bool
{
//InMemoryDocumentStore ignores indices
foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $index) {
if($index instanceof FieldIndex || $index instanceof MultiFieldIndex) {
if($index->name() === $indexName) {
return true;
}
}
}

return false;
}

Expand All @@ -94,7 +105,11 @@ public function hasCollectionIndex(string $collectionName, string $indexName): b
*/
public function addCollectionIndex(string $collectionName, Index $index): void
{
//InMemoryDocumentStore ignores indices
if($index instanceof FieldIndex || $index instanceof MultiFieldIndex) {
$this->dropCollectionIndex($collectionName, $index->name());
}

$this->inMemoryConnection['documentIndices'][] = $index;
}

/**
Expand All @@ -104,7 +119,27 @@ public function addCollectionIndex(string $collectionName, Index $index): void
*/
public function dropCollectionIndex(string $collectionName, $index): void
{
//InMemoryDocumentStore ignores indices
if(is_string($index)) {
foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $idxI => $existingIndex) {
if($existingIndex instanceof FieldIndex || $existingIndex instanceof MultiFieldIndex) {
if($existingIndex->name() === $index) {
unset($this->inMemoryConnection['documentIndices'][$collectionName][$idxI]);
}
}
}

$this->inMemoryConnection['documentIndices'][$collectionName] = array_values($this->inMemoryConnection['documentIndices'][$collectionName]);

return;
}

foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $idxI => $existingIndex) {
if($existingIndex === $index) {
unset($this->inMemoryConnection['documentIndices'][$collectionName][$idxI]);
}
}

$this->inMemoryConnection['documentIndices'][$collectionName] = array_values($this->inMemoryConnection['documentIndices'][$collectionName]);
}

/**
Expand All @@ -121,6 +156,8 @@ public function addDoc(string $collectionName, string $docId, array $doc): void
throw new RuntimeException("Cannot add doc with id $docId. The doc already exists in collection $collectionName");
}

$this->assertUniqueConstraints($collectionName, $docId, $doc);

$this->inMemoryConnection['documents'][$collectionName][$docId] = $doc;
}

Expand All @@ -133,8 +170,9 @@ public function addDoc(string $collectionName, string $docId, array $doc): void
public function updateDoc(string $collectionName, string $docId, array $docOrSubset): void
{
$this->assertDocExists($collectionName, $docId);
$this->assertUniqueConstraints($collectionName, $docId, $docOrSubset);

$this->inMemoryConnection['documents'][$collectionName][$docId] = \array_merge(
$this->inMemoryConnection['documents'][$collectionName][$docId] = \array_replace_recursive(
$this->inMemoryConnection['documents'][$collectionName][$docId],
$docOrSubset
);
Expand Down Expand Up @@ -228,11 +266,13 @@ public function filterDocs(
$filteredDocs = [];

foreach ($this->inMemoryConnection['documents'][$collectionName] as $docId => $doc) {
if ($filter->match($doc, $docId)) {
if ($filter->match($doc, (string)$docId)) {
$filteredDocs[$docId] = $doc;
}
}

$filteredDocs = \array_values($filteredDocs);

if ($orderBy !== null) {
$this->sort($filteredDocs, $orderBy);
}
Expand Down Expand Up @@ -271,6 +311,104 @@ private function assertDocExists(string $collectionName, string $docId): void
}
}

private function assertUniqueConstraints(string $collectionName, string $docId, array $docOrSubset): void
{
$indices = $this->inMemoryConnection['documentIndices'][$collectionName];

foreach ($indices as $index) {
if($index instanceof FieldIndex) {
$this->assertUniqueFieldConstraint($collectionName, $docId, $docOrSubset, $index);
}

if($index instanceof MultiFieldIndex) {
$this->assertMultiFieldUniqueConstraint($collectionName, $docId, $docOrSubset, $index);
}
}
}

private function assertUniqueFieldConstraint(string $collectionName, string $docId, array $docOrSubset, FieldIndex $index): void
{
if(!$index->unique()) {
return;
}

$reader = new ArrayReader($docOrSubset);

if(!$reader->pathExists($index->field())) {
return;
}

$value = $reader->mixedValue($index->field());

$check = new EqFilter($index->field(), $value);

$existingDocs = $this->filterDocs($collectionName, $check);

foreach ($existingDocs as $existingDoc) {
throw new RuntimeException(
"Unique constraint violation. Cannot insert or update document with id $docId, because a document with same value for field: {$index->field()} exists already!"
);
}

return;
}

private function assertMultiFieldUniqueConstraint(string $collectionName, string $docId, array $docOrSubset, MultiFieldIndex $index): void
{
if(!$index->unique()) {
return;
}

if($this->hasDoc($collectionName, $docId)) {
$effectedDoc = $this->getDoc($collectionName, $docId);
$docOrSubset = \array_replace_recursive($effectedDoc, $docOrSubset);
}

$reader = new ArrayReader($docOrSubset);

$checkList = [];
$notExistingFieldsCheckList = [];
$fieldNames = [];

foreach ($index->fields() as $fieldIndex) {
$fieldNames[] = $fieldIndex->field();
if($reader->pathExists($fieldIndex->field())) {
$checkList[] = new EqFilter($fieldIndex->field(), $reader->mixedValue($fieldIndex->field()));
} else {
$notExistingFieldsCheckList[] = new EqFilter($fieldIndex->field(), null);
}
}

if(count($checkList) === 0) {
return;
}

$checkList = array_merge($checkList, $notExistingFieldsCheckList);

if(count($checkList) > 1) {
$a = $checkList[0];
$b = $checkList[1];
$rest = array_slice($checkList, 2);
if(!$rest) {
$rest = [];
}
$checkList = new AndFilter($a, $b, ...$rest);
} else {
$checkList = $checkList[0];
}

$existingDocs = $this->filterDocs($collectionName, $checkList);

foreach ($existingDocs as $existingDoc) {
$fieldNamesStr = implode(", ", $fieldNames);
throw new RuntimeException(
"Unique constraint violation. Cannot insert or update document with id $docId, because a document with same values for fields: {$fieldNamesStr} exists already!"
);
}

return;
}

private function sort(&$docs, OrderBy $orderBy)
{
$defaultCmp = function ($a, $b) {
Expand Down
139 changes: 139 additions & 0 deletions tests/InMemoryDocumentStoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);

namespace EventEngineTest\DocumentStore;

use EventEngine\DocumentStore\FieldIndex;
use EventEngine\DocumentStore\Filter\EqFilter;
use EventEngine\DocumentStore\InMemoryDocumentStore;
use EventEngine\DocumentStore\MultiFieldIndex;
use EventEngine\Persistence\InMemoryConnection;
use PHPUnit\Framework\TestCase;

final class InMemoryDocumentStoreTest extends TestCase
{
/**
* @var InMemoryDocumentStore
*/
private $store;

protected function setUp()
{
parent::setUp();
$this->store = new InMemoryDocumentStore(new InMemoryConnection());
}

/**
* @test
*/
public function it_adds_collection()
{
$this->store->addCollection('test');
$this->assertTrue($this->store->hasCollection('test'));
}

/**
* @test
*/
public function it_adds_collection_with_unique_index()
{
$this->store->addCollection('test', FieldIndex::namedIndexForField('unique_prop_idx', 'some.prop', FieldIndex::SORT_ASC, true));
$this->assertTrue($this->store->hasCollectionIndex('test', 'unique_prop_idx'));
}

/**
* @test
*/
public function it_adds_and_updates_a_doc()
{
$this->store->addCollection('test');

$doc = [
'some' => [
'prop' => 'foo',
'other' => [
'nested' => 42
]
],
'baz' => 'bat',
];

$this->store->addDoc('test', '1', $doc);

$persistedDoc = $this->store->getDoc('test', '1');

$this->assertEquals($doc, $persistedDoc);

$doc['baz'] = 'changed val';

$this->store->updateDoc('test', '1', $doc);

$filter = new EqFilter('baz', 'changed val');

$filteredDocs = $this->store->filterDocs('test', $filter);

$this->assertCount(1, $filteredDocs);
}

/**
* @test
*/
public function it_updates_a_subset_of_a_doc()
{
$this->store->addCollection('test');

$doc = [
'some' => [
'prop' => 'foo',
'other' => [
'nested' => 42
]
],
'baz' => 'bat',
];

$this->store->addDoc('test', '1', $doc);

$this->store->updateDoc('test', '1', [
'some' => [
'prop' => 'fuzz'
]
]);

$filteredDocs = iterator_to_array($this->store->filterDocs('test', new EqFilter('some.prop', 'fuzz')));
$this->assertEquals(42, $filteredDocs[0]['some']['other']['nested']);
}

/**
* @test
*/
public function it_ensures_unique_constraints_for_a_field()
{
$this->store->addCollection('test', FieldIndex::namedIndexForField('unique_prop_idx', 'some.prop', FieldIndex::SORT_ASC, true));

$this->store->addDoc('test', '1', ['some' => ['prop' => 'foo']]);
$this->store->addDoc('test', '2', ['some' => ['prop' => 'bar']]);

$this->expectExceptionMessageRegExp('/^Unique constraint violation/');
$this->store->addDoc('test', '3', ['some' => ['prop' => 'foo']]);
}



/**
* @test
*/
public function it_ensures_unique_constraints_for_multiple_fields()
{
$multiFieldIndex = MultiFieldIndex::forFields(['some.prop', 'some.other.prop'], true);

$this->store->addCollection('test', $multiFieldIndex);

$this->store->addDoc('test', '1', ['some' => ['prop' => 'foo', 'other' => ['prop' => 'bat']]]);
$this->store->addDoc('test', '2', ['some' => ['prop' => 'bar', 'other' => ['prop' => 'bat']]]);
$this->store->addDoc('test', '3', ['some' => ['prop' => 'bar']]);

$this->expectExceptionMessageRegExp('/^Unique constraint violation/');
$this->store->updateDoc('test', '2', ['some' => ['prop' => 'foo']]);
}
}
17 changes: 17 additions & 0 deletions tests/Persistence/Exception/TransactionAlreadyStarted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
/**
* This file is part of event-engine/php-persistence.
* (c) 2018-2019 prooph software GmbH <contact@prooph.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace EventEngine\Persistence\Exception;

final class TransactionAlreadyStarted extends \RuntimeException implements TransactionException
{
protected $message = 'The transaction has already been started.';
}
Loading

0 comments on commit 308ebf1

Please sign in to comment.