Skip to content

Commit 308ebf1

Browse files
author
codeliner
committed
Support unique indices in InMemoryDocumentStore
1 parent 3bebe19 commit 308ebf1

11 files changed

+532
-6
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# php-document-store
22

33
Event Engine PHP Document Store Contract
4+
5+
## Testing
6+
7+
This package includes an in-memory implementation of the `DocumentStore` interface which is useful for tests.
8+
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.
9+
The implementation depends on classes from that other package, but we cannot pull it with composer due to circular dependencies.
10+
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.

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
},
3434
"autoload-dev": {
3535
"psr-4": {
36-
"EventEngineTest\\DocumentStore\\": "tests/"
36+
"EventEngineTest\\DocumentStore\\": "tests/",
37+
"EventEngine\\Persistence\\": "tests/Persistence"
3738
}
3839
},
3940
"prefer-stable": true,

src/InMemoryDocumentStore.php

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use Codeliner\ArrayReader\ArrayReader;
1515
use EventEngine\DocumentStore\Exception\RuntimeException;
1616
use EventEngine\DocumentStore\Exception\UnknownCollection;
17+
use EventEngine\DocumentStore\Filter\AndFilter;
18+
use EventEngine\DocumentStore\Filter\EqFilter;
1719
use EventEngine\DocumentStore\Filter\Filter;
1820
use EventEngine\DocumentStore\OrderBy\AndOrder;
1921
use EventEngine\DocumentStore\OrderBy\Asc;
@@ -68,6 +70,7 @@ public function hasCollection(string $collectionName): bool
6870
public function addCollection(string $collectionName, Index ...$indices): void
6971
{
7072
$this->inMemoryConnection['documents'][$collectionName] = [];
73+
$this->inMemoryConnection['documentIndices'][$collectionName] = $indices;
7174
}
7275

7376
/**
@@ -78,12 +81,20 @@ public function dropCollection(string $collectionName): void
7881
{
7982
if ($this->hasCollection($collectionName)) {
8083
unset($this->inMemoryConnection['documents'][$collectionName]);
84+
unset($this->inMemoryConnection['documentIndices'][$collectionName]);
8185
}
8286
}
8387

8488
public function hasCollectionIndex(string $collectionName, string $indexName): bool
8589
{
86-
//InMemoryDocumentStore ignores indices
90+
foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $index) {
91+
if($index instanceof FieldIndex || $index instanceof MultiFieldIndex) {
92+
if($index->name() === $indexName) {
93+
return true;
94+
}
95+
}
96+
}
97+
8798
return false;
8899
}
89100

@@ -94,7 +105,11 @@ public function hasCollectionIndex(string $collectionName, string $indexName): b
94105
*/
95106
public function addCollectionIndex(string $collectionName, Index $index): void
96107
{
97-
//InMemoryDocumentStore ignores indices
108+
if($index instanceof FieldIndex || $index instanceof MultiFieldIndex) {
109+
$this->dropCollectionIndex($collectionName, $index->name());
110+
}
111+
112+
$this->inMemoryConnection['documentIndices'][] = $index;
98113
}
99114

100115
/**
@@ -104,7 +119,27 @@ public function addCollectionIndex(string $collectionName, Index $index): void
104119
*/
105120
public function dropCollectionIndex(string $collectionName, $index): void
106121
{
107-
//InMemoryDocumentStore ignores indices
122+
if(is_string($index)) {
123+
foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $idxI => $existingIndex) {
124+
if($existingIndex instanceof FieldIndex || $existingIndex instanceof MultiFieldIndex) {
125+
if($existingIndex->name() === $index) {
126+
unset($this->inMemoryConnection['documentIndices'][$collectionName][$idxI]);
127+
}
128+
}
129+
}
130+
131+
$this->inMemoryConnection['documentIndices'][$collectionName] = array_values($this->inMemoryConnection['documentIndices'][$collectionName]);
132+
133+
return;
134+
}
135+
136+
foreach ($this->inMemoryConnection['documentIndices'][$collectionName] as $idxI => $existingIndex) {
137+
if($existingIndex === $index) {
138+
unset($this->inMemoryConnection['documentIndices'][$collectionName][$idxI]);
139+
}
140+
}
141+
142+
$this->inMemoryConnection['documentIndices'][$collectionName] = array_values($this->inMemoryConnection['documentIndices'][$collectionName]);
108143
}
109144

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

159+
$this->assertUniqueConstraints($collectionName, $docId, $doc);
160+
124161
$this->inMemoryConnection['documents'][$collectionName][$docId] = $doc;
125162
}
126163

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

137-
$this->inMemoryConnection['documents'][$collectionName][$docId] = \array_merge(
175+
$this->inMemoryConnection['documents'][$collectionName][$docId] = \array_replace_recursive(
138176
$this->inMemoryConnection['documents'][$collectionName][$docId],
139177
$docOrSubset
140178
);
@@ -228,11 +266,13 @@ public function filterDocs(
228266
$filteredDocs = [];
229267

230268
foreach ($this->inMemoryConnection['documents'][$collectionName] as $docId => $doc) {
231-
if ($filter->match($doc, $docId)) {
269+
if ($filter->match($doc, (string)$docId)) {
232270
$filteredDocs[$docId] = $doc;
233271
}
234272
}
235273

274+
$filteredDocs = \array_values($filteredDocs);
275+
236276
if ($orderBy !== null) {
237277
$this->sort($filteredDocs, $orderBy);
238278
}
@@ -271,6 +311,104 @@ private function assertDocExists(string $collectionName, string $docId): void
271311
}
272312
}
273313

314+
private function assertUniqueConstraints(string $collectionName, string $docId, array $docOrSubset): void
315+
{
316+
$indices = $this->inMemoryConnection['documentIndices'][$collectionName];
317+
318+
foreach ($indices as $index) {
319+
if($index instanceof FieldIndex) {
320+
$this->assertUniqueFieldConstraint($collectionName, $docId, $docOrSubset, $index);
321+
}
322+
323+
if($index instanceof MultiFieldIndex) {
324+
$this->assertMultiFieldUniqueConstraint($collectionName, $docId, $docOrSubset, $index);
325+
}
326+
}
327+
}
328+
329+
private function assertUniqueFieldConstraint(string $collectionName, string $docId, array $docOrSubset, FieldIndex $index): void
330+
{
331+
if(!$index->unique()) {
332+
return;
333+
}
334+
335+
$reader = new ArrayReader($docOrSubset);
336+
337+
if(!$reader->pathExists($index->field())) {
338+
return;
339+
}
340+
341+
$value = $reader->mixedValue($index->field());
342+
343+
$check = new EqFilter($index->field(), $value);
344+
345+
$existingDocs = $this->filterDocs($collectionName, $check);
346+
347+
foreach ($existingDocs as $existingDoc) {
348+
throw new RuntimeException(
349+
"Unique constraint violation. Cannot insert or update document with id $docId, because a document with same value for field: {$index->field()} exists already!"
350+
);
351+
}
352+
353+
return;
354+
}
355+
356+
private function assertMultiFieldUniqueConstraint(string $collectionName, string $docId, array $docOrSubset, MultiFieldIndex $index): void
357+
{
358+
if(!$index->unique()) {
359+
return;
360+
}
361+
362+
if($this->hasDoc($collectionName, $docId)) {
363+
$effectedDoc = $this->getDoc($collectionName, $docId);
364+
$docOrSubset = \array_replace_recursive($effectedDoc, $docOrSubset);
365+
}
366+
367+
$reader = new ArrayReader($docOrSubset);
368+
369+
$checkList = [];
370+
$notExistingFieldsCheckList = [];
371+
$fieldNames = [];
372+
373+
foreach ($index->fields() as $fieldIndex) {
374+
$fieldNames[] = $fieldIndex->field();
375+
if($reader->pathExists($fieldIndex->field())) {
376+
$checkList[] = new EqFilter($fieldIndex->field(), $reader->mixedValue($fieldIndex->field()));
377+
} else {
378+
$notExistingFieldsCheckList[] = new EqFilter($fieldIndex->field(), null);
379+
}
380+
}
381+
382+
if(count($checkList) === 0) {
383+
return;
384+
}
385+
386+
$checkList = array_merge($checkList, $notExistingFieldsCheckList);
387+
388+
if(count($checkList) > 1) {
389+
$a = $checkList[0];
390+
$b = $checkList[1];
391+
$rest = array_slice($checkList, 2);
392+
if(!$rest) {
393+
$rest = [];
394+
}
395+
$checkList = new AndFilter($a, $b, ...$rest);
396+
} else {
397+
$checkList = $checkList[0];
398+
}
399+
400+
$existingDocs = $this->filterDocs($collectionName, $checkList);
401+
402+
foreach ($existingDocs as $existingDoc) {
403+
$fieldNamesStr = implode(", ", $fieldNames);
404+
throw new RuntimeException(
405+
"Unique constraint violation. Cannot insert or update document with id $docId, because a document with same values for fields: {$fieldNamesStr} exists already!"
406+
);
407+
}
408+
409+
return;
410+
}
411+
274412
private function sort(&$docs, OrderBy $orderBy)
275413
{
276414
$defaultCmp = function ($a, $b) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace EventEngineTest\DocumentStore;
5+
6+
use EventEngine\DocumentStore\FieldIndex;
7+
use EventEngine\DocumentStore\Filter\EqFilter;
8+
use EventEngine\DocumentStore\InMemoryDocumentStore;
9+
use EventEngine\DocumentStore\MultiFieldIndex;
10+
use EventEngine\Persistence\InMemoryConnection;
11+
use PHPUnit\Framework\TestCase;
12+
13+
final class InMemoryDocumentStoreTest extends TestCase
14+
{
15+
/**
16+
* @var InMemoryDocumentStore
17+
*/
18+
private $store;
19+
20+
protected function setUp()
21+
{
22+
parent::setUp();
23+
$this->store = new InMemoryDocumentStore(new InMemoryConnection());
24+
}
25+
26+
/**
27+
* @test
28+
*/
29+
public function it_adds_collection()
30+
{
31+
$this->store->addCollection('test');
32+
$this->assertTrue($this->store->hasCollection('test'));
33+
}
34+
35+
/**
36+
* @test
37+
*/
38+
public function it_adds_collection_with_unique_index()
39+
{
40+
$this->store->addCollection('test', FieldIndex::namedIndexForField('unique_prop_idx', 'some.prop', FieldIndex::SORT_ASC, true));
41+
$this->assertTrue($this->store->hasCollectionIndex('test', 'unique_prop_idx'));
42+
}
43+
44+
/**
45+
* @test
46+
*/
47+
public function it_adds_and_updates_a_doc()
48+
{
49+
$this->store->addCollection('test');
50+
51+
$doc = [
52+
'some' => [
53+
'prop' => 'foo',
54+
'other' => [
55+
'nested' => 42
56+
]
57+
],
58+
'baz' => 'bat',
59+
];
60+
61+
$this->store->addDoc('test', '1', $doc);
62+
63+
$persistedDoc = $this->store->getDoc('test', '1');
64+
65+
$this->assertEquals($doc, $persistedDoc);
66+
67+
$doc['baz'] = 'changed val';
68+
69+
$this->store->updateDoc('test', '1', $doc);
70+
71+
$filter = new EqFilter('baz', 'changed val');
72+
73+
$filteredDocs = $this->store->filterDocs('test', $filter);
74+
75+
$this->assertCount(1, $filteredDocs);
76+
}
77+
78+
/**
79+
* @test
80+
*/
81+
public function it_updates_a_subset_of_a_doc()
82+
{
83+
$this->store->addCollection('test');
84+
85+
$doc = [
86+
'some' => [
87+
'prop' => 'foo',
88+
'other' => [
89+
'nested' => 42
90+
]
91+
],
92+
'baz' => 'bat',
93+
];
94+
95+
$this->store->addDoc('test', '1', $doc);
96+
97+
$this->store->updateDoc('test', '1', [
98+
'some' => [
99+
'prop' => 'fuzz'
100+
]
101+
]);
102+
103+
$filteredDocs = iterator_to_array($this->store->filterDocs('test', new EqFilter('some.prop', 'fuzz')));
104+
$this->assertEquals(42, $filteredDocs[0]['some']['other']['nested']);
105+
}
106+
107+
/**
108+
* @test
109+
*/
110+
public function it_ensures_unique_constraints_for_a_field()
111+
{
112+
$this->store->addCollection('test', FieldIndex::namedIndexForField('unique_prop_idx', 'some.prop', FieldIndex::SORT_ASC, true));
113+
114+
$this->store->addDoc('test', '1', ['some' => ['prop' => 'foo']]);
115+
$this->store->addDoc('test', '2', ['some' => ['prop' => 'bar']]);
116+
117+
$this->expectExceptionMessageRegExp('/^Unique constraint violation/');
118+
$this->store->addDoc('test', '3', ['some' => ['prop' => 'foo']]);
119+
}
120+
121+
122+
123+
/**
124+
* @test
125+
*/
126+
public function it_ensures_unique_constraints_for_multiple_fields()
127+
{
128+
$multiFieldIndex = MultiFieldIndex::forFields(['some.prop', 'some.other.prop'], true);
129+
130+
$this->store->addCollection('test', $multiFieldIndex);
131+
132+
$this->store->addDoc('test', '1', ['some' => ['prop' => 'foo', 'other' => ['prop' => 'bat']]]);
133+
$this->store->addDoc('test', '2', ['some' => ['prop' => 'bar', 'other' => ['prop' => 'bat']]]);
134+
$this->store->addDoc('test', '3', ['some' => ['prop' => 'bar']]);
135+
136+
$this->expectExceptionMessageRegExp('/^Unique constraint violation/');
137+
$this->store->updateDoc('test', '2', ['some' => ['prop' => 'foo']]);
138+
}
139+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
/**
3+
* This file is part of event-engine/php-persistence.
4+
* (c) 2018-2019 prooph software GmbH <contact@prooph.de>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace EventEngine\Persistence\Exception;
13+
14+
final class TransactionAlreadyStarted extends \RuntimeException implements TransactionException
15+
{
16+
protected $message = 'The transaction has already been started.';
17+
}

0 commit comments

Comments
 (0)