diff --git a/composer.json b/composer.json index 90d005e..8470564 100644 --- a/composer.json +++ b/composer.json @@ -16,21 +16,22 @@ } ], "require": { - "php": "^7.1", + "php": "^7.2 || ^8.0", + "ext-json": "*", "ext-pdo": "*", - "event-engine/php-persistence": "^0.4" + "event-engine/php-persistence": "^0.9" }, "require-dev": { + "infection/infection": "^0.26.6", + "malukenho/docheader": "^0.1.8", + "phpspec/prophecy": "^1.12.1", + "phpstan/phpstan": "^0.12.48", + "phpstan/phpstan-strict-rules": "^0.12.5", + "phpunit/phpunit": "^8.5.8", + "prooph/php-cs-fixer-config": "^0.4.0", + "ramsey/uuid" : "^4.1.1", "roave/security-advisories": "dev-master", - "ramsey/uuid" : "^3.6", - "infection/infection": "^0.11.0", - "malukenho/docheader": "^0.1.4", - "phpspec/prophecy": "^1.7", - "phpstan/phpstan": "^0.10.5", - "phpstan/phpstan-strict-rules": "^0.10.1", - "phpunit/phpunit": "^8.0", - "prooph/php-cs-fixer-config": "^0.3", - "satooshi/php-coveralls": "^1.0" + "php-coveralls/php-coveralls": "^2.2.0" }, "autoload": { "psr-4": { @@ -45,6 +46,10 @@ "config": { "sort-packages": true, "platform": { + }, + "allow-plugins": { + "ocramius/package-versions": true, + "infection/extension-installer": true } }, "prefer-stable": true, diff --git a/docker-compose.yml b/docker-compose.yml index b7c1b34..18581e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,14 @@ version: '2' services: php: - image: prooph/php:7.2-cli + image: prooph/php:8.0-cli volumes: - .:/app environment: - PROOPH_ENV=dev - PDO_DSN=pgsql:host=postgres port=5432 dbname=event_engine - PDO_USER=postgres - - PDO_PWD= + - PDO_PWD=test postgres: image: postgres:alpine @@ -17,3 +17,4 @@ services: - 5432:5432 environment: - POSTGRES_DB=event_engine + - POSTGRES_PASSWORD=test diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f0ed2fe..529e2db 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,7 +7,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="false" bootstrap="vendor/autoload.php" > @@ -22,7 +21,7 @@ - + diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index 6cb0c61..4d271f4 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff --git a/src/Exception/PostgresDocumentStoreException.php b/src/Exception/PostgresDocumentStoreException.php index b58a56f..a4de8ee 100644 --- a/src/Exception/PostgresDocumentStoreException.php +++ b/src/Exception/PostgresDocumentStoreException.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php index d0c6274..efef111 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/RuntimeException.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff --git a/src/Filter/FilterClause.php b/src/Filter/FilterClause.php new file mode 100644 index 0000000..8147c88 --- /dev/null +++ b/src/Filter/FilterClause.php @@ -0,0 +1,34 @@ + + * + * 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\DocumentStore\Postgres\Filter; + +final class FilterClause +{ + private $clause; + private $args; + + public function __construct(?string $clause, array $args = []) + { + $this->clause = $clause; + $this->args = $args; + } + + public function clause(): ?string + { + return $this->clause; + } + + public function args(): array + { + return $this->args; + } +} diff --git a/src/Filter/FilterProcessor.php b/src/Filter/FilterProcessor.php new file mode 100644 index 0000000..3a62c53 --- /dev/null +++ b/src/Filter/FilterProcessor.php @@ -0,0 +1,19 @@ + + * + * 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\DocumentStore\Postgres\Filter; + +use EventEngine\DocumentStore\Filter\Filter; + +interface FilterProcessor +{ + public function process(Filter $filter): FilterClause; +} diff --git a/src/Filter/PostgresFilterProcessor.php b/src/Filter/PostgresFilterProcessor.php new file mode 100644 index 0000000..c8091c3 --- /dev/null +++ b/src/Filter/PostgresFilterProcessor.php @@ -0,0 +1,199 @@ + + * + * 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\DocumentStore\Postgres\Filter; + +use EventEngine\DocumentStore; +use EventEngine\DocumentStore\Filter\Filter; +use EventEngine\DocumentStore\Postgres\Exception\InvalidArgumentException; +use EventEngine\DocumentStore\Postgres\Exception\RuntimeException; + +/** + * Default filter processor class for converting a filter to a where clause. + */ +final class PostgresFilterProcessor implements FilterProcessor +{ + /** + * @var bool + */ + private $useMetadataColumns; + + public function __construct(bool $useMetadataColumns = false) + { + $this->useMetadataColumns = $useMetadataColumns; + } + + public function process(Filter $filter): FilterClause + { + [$filterClause, $args] = $this->processFilter($filter); + + return new FilterClause($filterClause, $args); + } + + /** + * @param Filter $filter + * @param int $argsCount + * @return array + */ + private function processFilter(Filter $filter, int $argsCount = 0): array + { + if($filter instanceof DocumentStore\Filter\AnyFilter) { + if($argsCount > 0) { + throw new InvalidArgumentException('AnyFilter cannot be used together with other filters.'); + } + return [null, [], $argsCount]; + } + + if($filter instanceof DocumentStore\Filter\AndFilter) { + [$filterA, $argsA, $argsCount] = $this->processFilter($filter->aFilter(), $argsCount); + [$filterB, $argsB, $argsCount] = $this->processFilter($filter->bFilter(), $argsCount); + return ["($filterA AND $filterB)", array_merge($argsA, $argsB), $argsCount]; + } + + if($filter instanceof DocumentStore\Filter\OrFilter) { + [$filterA, $argsA, $argsCount] = $this->processFilter($filter->aFilter(), $argsCount); + [$filterB, $argsB, $argsCount] = $this->processFilter($filter->bFilter(), $argsCount); + return ["($filterA OR $filterB)", array_merge($argsA, $argsB), $argsCount]; + } + + switch (get_class($filter)) { + case DocumentStore\Filter\DocIdFilter::class: + /** @var DocumentStore\Filter\DocIdFilter $filter */ + return ["id = :a$argsCount", ["a$argsCount" => $filter->val()], ++$argsCount]; + case DocumentStore\Filter\AnyOfDocIdFilter::class: + /** @var DocumentStore\Filter\AnyOfDocIdFilter $filter */ + return $this->makeInClause('id', $filter->valList(), $argsCount); + case DocumentStore\Filter\AnyOfFilter::class: + /** @var DocumentStore\Filter\AnyOfFilter $filter */ + return $this->makeInClause($this->propToJsonPath($filter->prop()), $filter->valList(), $argsCount, $this->shouldJsonEncodeVal($filter->prop())); + case DocumentStore\Filter\EqFilter::class: + /** @var DocumentStore\Filter\EqFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop = :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; + case DocumentStore\Filter\GtFilter::class: + /** @var DocumentStore\Filter\GtFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop > :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; + case DocumentStore\Filter\GteFilter::class: + /** @var DocumentStore\Filter\GteFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop >= :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; + case DocumentStore\Filter\LtFilter::class: + /** @var DocumentStore\Filter\LtFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop < :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; + case DocumentStore\Filter\LteFilter::class: + /** @var DocumentStore\Filter\LteFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop <= :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; + case DocumentStore\Filter\LikeFilter::class: + /** @var DocumentStore\Filter\LikeFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + $propParts = explode('->', $prop); + $lastProp = array_pop($propParts); + $prop = implode('->', $propParts) . '->>'.$lastProp; + return ["$prop iLIKE :a$argsCount", ["a$argsCount" => $filter->val()], ++$argsCount]; + case DocumentStore\Filter\NotFilter::class: + /** @var DocumentStore\Filter\NotFilter $filter */ + $innerFilter = $filter->innerFilter(); + + if (!$this->isPropFilter($innerFilter)) { + throw new RuntimeException('Not filter cannot be combined with a non prop filter!'); + } + + [$innerFilterStr, $args, $argsCount] = $this->processFilter($innerFilter, $argsCount); + + if($innerFilter instanceof DocumentStore\Filter\AnyOfFilter || $innerFilter instanceof DocumentStore\Filter\AnyOfDocIdFilter) { + if ($argsCount === 0) { + return [ + str_replace(' 1 != 1 ', ' 1 = 1 ', $innerFilterStr), + $args, + $argsCount + ]; + } + + $inPos = strpos($innerFilterStr, ' IN('); + $filterStr = substr_replace($innerFilterStr, ' NOT IN(', $inPos, 4 /* " IN(" */); + return [$filterStr, $args, $argsCount]; + } + + return ["NOT $innerFilterStr", $args, $argsCount]; + case DocumentStore\Filter\InArrayFilter::class: + /** @var DocumentStore\Filter\InArrayFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + return ["$prop @> :a$argsCount", ["a$argsCount" => '[' . $this->prepareVal($filter->val(), $filter->prop()) . ']'], ++$argsCount]; + case DocumentStore\Filter\ExistsFilter::class: + /** @var DocumentStore\Filter\ExistsFilter $filter */ + $prop = $this->propToJsonPath($filter->prop()); + $propParts = explode('->', $prop); + $lastProp = trim(array_pop($propParts), "'"); + $parentProps = implode('->', $propParts); + return ["JSONB_EXISTS($parentProps, '$lastProp')", [], $argsCount]; + default: + throw new RuntimeException('Unsupported filter type. Got ' . get_class($filter)); + } + } + + private function makeInClause(string $prop, array $valList, int $argsCount, bool $jsonEncode = false): array + { + if ($valList === []) { + return [' 1 != 1 ', [], 0]; + } + $argList = []; + $params = \implode(",", \array_map(function ($val) use (&$argsCount, &$argList, $jsonEncode) { + $param = ":a$argsCount"; + $argList["a$argsCount"] = $jsonEncode? \json_encode($val) : $val; + $argsCount++; + return $param; + }, $valList)); + + return ["$prop IN($params)", $argList, $argsCount]; + } + + private function shouldJsonEncodeVal(string $prop): bool + { + if($this->useMetadataColumns && strpos($prop, 'metadata.') === 0) { + return false; + } + + return true; + } + + private function propToJsonPath(string $field): string + { + if($this->useMetadataColumns && strpos($field, 'metadata.') === 0) { + return str_replace('metadata.', '', $field); + } + + return "doc->'" . str_replace('.', "'->'", $field) . "'"; + } + + private function isPropFilter(Filter $filter): bool + { + switch (get_class($filter)) { + case DocumentStore\Filter\AndFilter::class: + case DocumentStore\Filter\OrFilter::class: + case DocumentStore\Filter\NotFilter::class: + return false; + default: + return true; + } + } + + private function prepareVal($value, string $prop) + { + if(!$this->shouldJsonEncodeVal($prop)) { + return $value; + } + + return \json_encode($value); + } +} diff --git a/src/Index/RawSqlIndexCmd.php b/src/Index/RawSqlIndexCmd.php index 87c6c8c..01d6a07 100644 --- a/src/Index/RawSqlIndexCmd.php +++ b/src/Index/RawSqlIndexCmd.php @@ -1,4 +1,12 @@ + * + * 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\DocumentStore\Postgres\Index; @@ -27,7 +35,7 @@ public static function fromArray(array $data): Index return new self($data['sql'], $data['name'] ?? null); } - public function __construct(string $sql, string $name = null) + public function __construct(string $sql, ?string $name = null) { $this->sql = $sql; $this->name = $name; diff --git a/src/Metadata/Column.php b/src/Metadata/Column.php index 276a893..2101565 100644 --- a/src/Metadata/Column.php +++ b/src/Metadata/Column.php @@ -1,4 +1,12 @@ + * + * 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\DocumentStore\Postgres\Metadata; diff --git a/src/Metadata/MetadataColumnIndex.php b/src/Metadata/MetadataColumnIndex.php index 02e98a6..4948466 100644 --- a/src/Metadata/MetadataColumnIndex.php +++ b/src/Metadata/MetadataColumnIndex.php @@ -1,4 +1,12 @@ + * + * 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\DocumentStore\Postgres\Metadata; diff --git a/src/OrderBy/OrderByClause.php b/src/OrderBy/OrderByClause.php new file mode 100644 index 0000000..3567467 --- /dev/null +++ b/src/OrderBy/OrderByClause.php @@ -0,0 +1,34 @@ + + * + * 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\DocumentStore\Postgres\OrderBy; + +final class OrderByClause +{ + private $clause; + private $args; + + public function __construct(?string $clause, array $args = []) + { + $this->clause = $clause; + $this->args = $args; + } + + public function clause(): ?string + { + return $this->clause; + } + + public function args(): array + { + return $this->args; + } +} diff --git a/src/OrderBy/OrderByProcessor.php b/src/OrderBy/OrderByProcessor.php new file mode 100644 index 0000000..3c04b47 --- /dev/null +++ b/src/OrderBy/OrderByProcessor.php @@ -0,0 +1,19 @@ + + * + * 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\DocumentStore\Postgres\OrderBy; + +use EventEngine\DocumentStore\OrderBy\OrderBy; + +interface OrderByProcessor +{ + public function process(OrderBy $orderBy): OrderByClause; +} diff --git a/src/OrderBy/PostgresOrderByProcessor.php b/src/OrderBy/PostgresOrderByProcessor.php new file mode 100644 index 0000000..4d6ef4e --- /dev/null +++ b/src/OrderBy/PostgresOrderByProcessor.php @@ -0,0 +1,60 @@ + + * + * 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\DocumentStore\Postgres\OrderBy; + +use EventEngine\DocumentStore; +use EventEngine\DocumentStore\OrderBy\OrderBy; + +final class PostgresOrderByProcessor implements OrderByProcessor +{ + /** + * @var bool + */ + private $useMetadataColumns; + + public function __construct(bool $useMetadataColumns = false) + { + $this->useMetadataColumns = $useMetadataColumns; + } + + public function process(OrderBy $orderBy): OrderByClause + { + [$orderByClause, $args] = $this->processOrderBy($orderBy); + + return new OrderByClause($orderByClause, $args); + } + + private function processOrderBy(OrderBy $orderBy): array + { + if($orderBy instanceof DocumentStore\OrderBy\AndOrder) { + [$sortA, $sortAArgs] = $this->processOrderBy($orderBy->a()); + [$sortB, $sortBArgs] = $this->processOrderBy($orderBy->b()); + + return ["$sortA, $sortB", array_merge($sortAArgs, $sortBArgs)]; + } + + /** @var DocumentStore\OrderBy\Asc|DocumentStore\OrderBy\Desc $orderBy */ + $direction = $orderBy instanceof DocumentStore\OrderBy\Asc ? 'ASC' : 'DESC'; + $prop = $this->propToJsonPath($orderBy->prop()); + + return ["{$prop} $direction", []]; + } + + private function propToJsonPath(string $field): string + { + if($this->useMetadataColumns && strpos($field, 'metadata.') === 0) { + return str_replace('metadata.', '', $field); + } + + return "doc->'" . str_replace('.', "'->'", $field) . "'"; + } +} diff --git a/src/PostgresDocumentStore.php b/src/PostgresDocumentStore.php index 3eea1b4..23cf01b 100644 --- a/src/PostgresDocumentStore.php +++ b/src/PostgresDocumentStore.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -15,35 +15,72 @@ use EventEngine\DocumentStore\Filter\Filter; use EventEngine\DocumentStore\Index; use EventEngine\DocumentStore\OrderBy\OrderBy; -use EventEngine\DocumentStore\Postgres\Exception\InvalidArgumentException; +use EventEngine\DocumentStore\PartialSelect; use EventEngine\DocumentStore\Postgres\Exception\RuntimeException; +use EventEngine\DocumentStore\Postgres\Filter\FilterProcessor; +use EventEngine\DocumentStore\Postgres\Filter\PostgresFilterProcessor; +use EventEngine\DocumentStore\Postgres\OrderBy\OrderByClause; +use EventEngine\DocumentStore\Postgres\OrderBy\OrderByProcessor; +use EventEngine\DocumentStore\Postgres\OrderBy\PostgresOrderByProcessor; use EventEngine\Util\VariableType; +use function implode; +use function is_string; +use function json_decode; +use function mb_strlen; +use function mb_substr; +use function sprintf; + final class PostgresDocumentStore implements DocumentStore\DocumentStore { + private const PARTIAL_SELECT_DOC_ID = '__partial_sel_doc_id__'; + private const PARTIAL_SELECT_MERGE = '__partial_sel_merge__'; + /** * @var \PDO */ private $connection; + /** + * @var FilterProcessor + */ + private $filterProcessor; + + /** + * @var OrderByProcessor + */ + private $orderByProcessor; + private $tablePrefix = 'em_ds_'; private $docIdSchema = 'UUID NOT NULL'; private $manageTransactions; - private $useMetadataColumns = false; + private $useMetadataColumns; public function __construct( \PDO $connection, - string $tablePrefix = null, - string $docIdSchema = null, + ?string $tablePrefix = null, + ?string $docIdSchema = null, bool $transactional = true, - bool $useMetadataColumns = false + bool $useMetadataColumns = false, + ?FilterProcessor $filterProcessor = null, + ?OrderByProcessor $orderByProcessor = null ) { $this->connection = $connection; $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + if (null === $filterProcessor) { + $filterProcessor = new PostgresFilterProcessor($useMetadataColumns); + } + $this->filterProcessor = $filterProcessor; + + if (null === $orderByProcessor) { + $orderByProcessor = new PostgresOrderByProcessor($useMetadataColumns); + } + $this->orderByProcessor = $orderByProcessor; + if(null !== $tablePrefix) { $this->tablePrefix = $tablePrefix; } @@ -119,6 +156,7 @@ public function hasCollection(string $collectionName): bool SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_NAME = '{$this->tableName($collectionName)}' +AND TABLE_SCHEMA = '{$this->schemaName($collectionName)}' EOT; $stmt = $this->connection->prepare($query); @@ -147,8 +185,10 @@ public function addCollection(string $collectionName, Index ...$indices): void } } + $createSchemaCmd = "CREATE SCHEMA IF NOT EXISTS {$this->schemaName($collectionName)}"; + $cmd = <<tableName($collectionName)} ( +CREATE TABLE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} ( id {$this->docIdSchema}, doc JSONB NOT NULL, $metadataColumns @@ -160,7 +200,8 @@ public function addCollection(string $collectionName, Index ...$indices): void return $this->indexToSqlCmd($index, $collectionName); }, $indices); - $this->transactional(function() use ($cmd, $indicesCmds) { + $this->transactional(function() use ($createSchemaCmd, $cmd, $indicesCmds) { + $this->connection->prepare($createSchemaCmd)->execute(); $this->connection->prepare($cmd)->execute(); array_walk($indicesCmds, function ($cmd) { @@ -176,7 +217,7 @@ public function addCollection(string $collectionName, Index ...$indices): void public function dropCollection(string $collectionName): void { $cmd = <<tableName($collectionName)}; +DROP TABLE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)}; EOT; $this->transactional(function () use ($cmd) { @@ -190,6 +231,7 @@ public function hasCollectionIndex(string $collectionName, string $indexName): b SELECT INDEXNAME FROM pg_indexes WHERE TABLENAME = '{$this->tableName($collectionName)}' +AND SCHEMANAME = '{$this->schemaName($collectionName)}' AND INDEXNAME = '$indexName' EOT; @@ -222,7 +264,7 @@ public function addCollectionIndex(string $collectionName, Index $index): void $columnsSql = substr($columnsSql, 2); $metadataColumnCmd = <<tableName($collectionName)} +ALTER TABLE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} $columnsSql; EOT; @@ -262,7 +304,7 @@ public function dropCollectionIndex(string $collectionName, $index): void $columnsSql = substr($columnsSql, 2); $metadataColumnCmd = <<tableName($collectionName)} +ALTER TABLE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} $columnsSql; EOT; $index = $index->indexCmd(); @@ -312,7 +354,9 @@ public function addDoc(string $collectionName, string $docId, array $doc): void } $cmd = <<tableName($collectionName)} (id, doc{$metadataKeysStr}) VALUES (:id, :doc{$metadataValsStr}); +INSERT INTO {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} ( + id, doc{$metadataKeysStr}) VALUES (:id, :doc{$metadataValsStr} +); EOT; $this->transactional(function () use ($cmd, $docId, $doc, $metadata) { @@ -345,7 +389,7 @@ public function updateDoc(string $collectionName, string $docId, array $docOrSub } $cmd = <<tableName($collectionName)} +UPDATE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} SET doc = (to_jsonb(doc) || :doc){$metadataStr} WHERE id = :id ; @@ -366,9 +410,11 @@ public function updateDoc(string $collectionName, string $docId, array $docOrSub */ public function updateMany(string $collectionName, Filter $filter, array $set): void { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); - $where = $filterStr? "WHERE $filterStr" : ''; + $where = $filterStr ? "WHERE $filterStr" : ''; $metadataStr = ''; $metadata = []; @@ -384,7 +430,7 @@ public function updateMany(string $collectionName, Filter $filter, array $set): } $cmd = <<tableName($collectionName)} +UPDATE {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} SET doc = (to_jsonb(doc) || :doc){$metadataStr} $where; EOT; @@ -409,13 +455,89 @@ public function upsertDoc(string $collectionName, string $docId, array $docOrSub { $doc = $this->getDoc($collectionName, $docId); - if($doc) { + if($doc !== null) { $this->updateDoc($collectionName, $docId, $docOrSubset); } else { $this->addDoc($collectionName, $docId, $docOrSubset); } } + /** + * @param string $collectionName + * @param string $docId + * @param array $doc + * @throws \Throwable if updating did not succeed + */ + public function replaceDoc(string $collectionName, string $docId, array $doc): void + { + $metadataStr = ''; + $metadata = []; + + if($this->useMetadataColumns && array_key_exists('metadata', $doc)) { + $metadata = $doc['metadata']; + unset($doc['metadata']); + + + foreach ($metadata as $k => $v) { + $metadataStr .= ', '.$k.' = :'.$k; + } + } + + $cmd = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +SET doc = :doc{$metadataStr} +WHERE id = :id +; +EOT; + $this->transactional(function () use ($cmd, $docId, $doc, $metadata) { + $this->connection->prepare($cmd)->execute(array_merge([ + 'id' => $docId, + 'doc' => json_encode($doc) + ], $metadata)); + }); + } + + /** + * @param string $collectionName + * @param Filter $filter + * @param array $set + * @throws \Throwable in case of connection error or other issues + */ + public function replaceMany(string $collectionName, Filter $filter, array $set): void + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $where = $filterStr? "WHERE $filterStr" : ''; + + $metadataStr = ''; + $metadata = []; + + if($this->useMetadataColumns && array_key_exists('metadata', $set)) { + $metadata = $set['metadata']; + unset($set['metadata']); + + + foreach ($metadata as $k => $v) { + $metadataStr .= ', '.$k.' = :'.$k; + } + } + + $cmd = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +SET doc = :doc{$metadataStr} +$where; +EOT; + + $args['doc'] = json_encode($set); + $args = array_merge($args, $metadata); + + $this->transactional(function () use ($cmd, $args) { + $this->connection->prepare($cmd)->execute($args); + }); + } + /** * @param string $collectionName * @param string $docId @@ -424,7 +546,7 @@ public function upsertDoc(string $collectionName, string $docId, array $docOrSub public function deleteDoc(string $collectionName, string $docId): void { $cmd = <<tableName($collectionName)} +DELETE FROM {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} WHERE id = :id EOT; @@ -442,12 +564,14 @@ public function deleteDoc(string $collectionName, string $docId): void */ public function deleteMany(string $collectionName, Filter $filter): void { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); $where = $filterStr? "WHERE $filterStr" : ''; $cmd = <<tableName($collectionName)} +DELETE FROM {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} $where; EOT; @@ -465,7 +589,7 @@ public function getDoc(string $collectionName, string $docId): ?array { $query = <<tableName($collectionName)} +FROM {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} WHERE id = :id EOT; $stmt = $this->connection->prepare($query); @@ -482,27 +606,53 @@ public function getDoc(string $collectionName, string $docId): ?array } /** - * @param string $collectionName - * @param Filter $filter - * @param int|null $skip - * @param int|null $limit - * @param OrderBy|null $orderBy - * @return \Traversable list of docs + * @inheritDoc */ - public function filterDocs(string $collectionName, Filter $filter, int $skip = null, int $limit = null, OrderBy $orderBy = null): \Traversable + public function getPartialDoc(string $collectionName, PartialSelect $partialSelect, string $docId): ?array { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $select = $this->makeSelect($partialSelect); - $where = $filterStr? "WHERE $filterStr" : ''; + $query = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +WHERE id = :id +EOT; + $stmt = $this->connection->prepare($query); + + $stmt->execute(['id' => $docId]); + + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if(!$row) { + return null; + } + + return $this->transformPartialDoc($partialSelect, $row); + } + + /** + * @inheritDoc + */ + public function filterDocs(string $collectionName, Filter $filter, ?int $skip = null, ?int $limit = null, ?OrderBy $orderBy = null): \Traversable + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $orderByClause = $orderBy ? $this->orderByProcessor->process($orderBy) : new OrderByClause(null, []); + $orderByStr = $orderByClause->clause(); + $orderByArgs = $orderByClause->args(); + + $where = $filterStr ? "WHERE $filterStr" : ''; $offset = $skip !== null ? "OFFSET $skip" : ''; $limit = $limit !== null ? "LIMIT $limit" : ''; - $orderBy = $orderBy ? "ORDER BY " . implode(', ', $this->orderByToSort($orderBy)) : ''; + $orderBy = $orderByStr ? "ORDER BY $orderByStr" : ''; $query = <<tableName($collectionName)} +FROM {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} $where $orderBy $limit @@ -510,13 +660,137 @@ public function filterDocs(string $collectionName, Filter $filter, int $skip = n EOT; $stmt = $this->connection->prepare($query); - $stmt->execute($args); + $stmt->execute(array_merge($args, $orderByArgs)); while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { yield json_decode($row['doc'], true); } } + /** + * @inheritDoc + */ + public function findDocs(string $collectionName, Filter $filter, ?int $skip = null, ?int $limit = null, ?OrderBy $orderBy = null): \Traversable + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $orderByClause = $orderBy ? $this->orderByProcessor->process($orderBy) : new OrderByClause(null, []); + $orderByStr = $orderByClause->clause(); + $orderByArgs = $orderByClause->args(); + + $where = $filterStr ? "WHERE $filterStr" : ''; + + $offset = $skip !== null ? "OFFSET $skip" : ''; + $limit = $limit !== null ? "LIMIT $limit" : ''; + + $orderBy = $orderByStr ? "ORDER BY $orderByStr" : ''; + + $query = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +$where +$orderBy +$limit +$offset; +EOT; + $stmt = $this->connection->prepare($query); + + $stmt->execute(array_merge($args, $orderByArgs)); + + while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + yield $row['id'] => json_decode($row['doc'], true); + } + } + + public function findPartialDocs(string $collectionName, PartialSelect $partialSelect, Filter $filter, ?int $skip = null, ?int $limit = null, ?OrderBy $orderBy = null): \Traversable + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $orderByClause = $orderBy ? $this->orderByProcessor->process($orderBy) : new OrderByClause(null, []); + $orderByStr = $orderByClause->clause(); + $orderByArgs = $orderByClause->args(); + + $select = $this->makeSelect($partialSelect); + + $where = $filterStr ? "WHERE $filterStr" : ''; + + $offset = $skip !== null ? "OFFSET $skip" : ''; + $limit = $limit !== null ? "LIMIT $limit" : ''; + + $orderBy = $orderByStr ? "ORDER BY $orderByStr" : ''; + + $query = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +$where +$orderBy +$limit +$offset; +EOT; + + $stmt = $this->connection->prepare($query); + + $stmt->execute(array_merge($args, $orderByArgs)); + + while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + yield $row[self::PARTIAL_SELECT_DOC_ID] => $this->transformPartialDoc($partialSelect, $row); + } + } + + /** + * @param string $collectionName + * @param Filter $filter + * @return array + */ + public function filterDocIds(string $collectionName, Filter $filter): array + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $where = $filterStr ? "WHERE {$filterStr}" : ''; + $query = "SELECT id FROM {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} {$where}"; + + $stmt = $this->connection->prepare($query); + $stmt->execute($args); + + $docIds = []; + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $docIds[] = $row['id']; + } + + return $docIds; + } + + /** + * @param string $collectionName + * @param Filter $filter + * @return int number of docs + */ + public function countDocs(string $collectionName, Filter $filter): int + { + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); + + $where = $filterStr? "WHERE $filterStr" : ''; + + $query = <<schemaName($collectionName)}.{$this->tableName($collectionName)} +$where; +EOT; + $stmt = $this->connection->prepare($query); + + $stmt->execute($args); + + return (int) $stmt->fetchColumn(0); + } + private function transactional(callable $callback) { if($this->manageTransactions) { @@ -536,151 +810,90 @@ private function transactional(callable $callback) } } - private function filterToWhereClause(Filter $filter, $argsCount = 0): array + private function propToJsonPath(string $field): string { - if($filter instanceof DocumentStore\Filter\AnyFilter) { - if($argsCount > 0) { - throw new InvalidArgumentException('AnyFilter cannot be used together with other filters.'); - } - return [null, [], $argsCount]; + if($this->useMetadataColumns && strpos($field, 'metadata.') === 0) { + return str_replace('metadata.', '', $field); } - if($filter instanceof DocumentStore\Filter\AndFilter) { - [$filterA, $argsA, $argsCount] = $this->filterToWhereClause($filter->aFilter(), $argsCount); - [$filterB, $argsB, $argsCount] = $this->filterToWhereClause($filter->bFilter(), $argsCount); - return ["($filterA AND $filterB)", array_merge($argsA, $argsB), $argsCount]; - } + return "doc->'" . str_replace('.', "'->'", $field) . "'"; + } - if($filter instanceof DocumentStore\Filter\OrFilter) { - [$filterA, $argsA, $argsCount] = $this->filterToWhereClause($filter->aFilter(), $argsCount); - [$filterB, $argsB, $argsCount] = $this->filterToWhereClause($filter->bFilter(), $argsCount); - return ["($filterA OR $filterB)", array_merge($argsA, $argsB), $argsCount]; - } + private function makeSelect(PartialSelect $partialSelect): string + { + $select = 'id as "'.self::PARTIAL_SELECT_DOC_ID.'", '; - switch (get_class($filter)) { - case DocumentStore\Filter\DocIdFilter::class: - /** @var DocumentStore\Filter\DocIdFilter $filter */ - return ["id = :a$argsCount", ["a$argsCount" => $filter->val()], ++$argsCount]; - case DocumentStore\Filter\AnyOfDocIdFilter::class: - /** @var DocumentStore\Filter\AnyOfDocIdFilter $filter */ - return $this->makeInClause('id', $filter->valList(), $argsCount); - case DocumentStore\Filter\AnyOfFilter::class: - /** @var DocumentStore\Filter\AnyOfFilter $filter */ - return $this->makeInClause($this->propToJsonPath($filter->prop()), $filter->valList(), $argsCount, $this->shouldJsonEncodeVal($filter->prop())); - case DocumentStore\Filter\EqFilter::class: - /** @var DocumentStore\Filter\EqFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop = :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\GtFilter::class: - /** @var DocumentStore\Filter\GtFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop > :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\GteFilter::class: - /** @var DocumentStore\Filter\GteFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop >= :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\LtFilter::class: - /** @var DocumentStore\Filter\LtFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop < :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\LteFilter::class: - /** @var DocumentStore\Filter\LteFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop <= :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\LikeFilter::class: - /** @var DocumentStore\Filter\LikeFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - $propParts = explode('->', $prop); - $lastProp = array_pop($propParts); - $prop = implode('->', $propParts) . '->>'.$lastProp; - return ["$prop LIKE :a$argsCount", ["a$argsCount" => $filter->val()], ++$argsCount]; - case DocumentStore\Filter\NotFilter::class: - /** @var DocumentStore\Filter\NotFilter $filter */ - $innerFilter = $filter->innerFilter(); - - if (!$this->isPropFilter($innerFilter)) { - throw new RuntimeException('Not filter cannot be combined with a non prop filter!'); - } + foreach ($partialSelect->fieldAliasMap() as $mapItem) { - [$innerFilterStr, $args, $argsCount] = $this->filterToWhereClause($innerFilter); + if($mapItem['alias'] === self::PARTIAL_SELECT_DOC_ID) { + throw new RuntimeException(sprintf( + "Invalid select alias. You cannot use %s as alias, because it is reserved for internal use", + self::PARTIAL_SELECT_DOC_ID + )); + } - if($innerFilter instanceof DocumentStore\Filter\AnyOfFilter || $innerFilter instanceof DocumentStore\Filter\AnyOfDocIdFilter) { - $inPos = strpos($innerFilterStr, ' IN('); - $filterStr = substr_replace($innerFilterStr, ' NOT IN(', $inPos, 4 /* " IN(" */); - return [$filterStr, $args, $argsCount]; - } + if($mapItem['alias'] === self::PARTIAL_SELECT_MERGE) { + throw new RuntimeException(sprintf( + "Invalid select alias. You cannot use %s as alias, because it is reserved for internal use", + self::PARTIAL_SELECT_MERGE + )); + } - return ["NOT $innerFilterStr", $args, $argsCount]; - case DocumentStore\Filter\InArrayFilter::class: - /** @var DocumentStore\Filter\InArrayFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop @> :a$argsCount", ["a$argsCount" => $this->prepareVal($filter->val(), $filter->prop())], ++$argsCount]; - case DocumentStore\Filter\ExistsFilter::class: - /** @var DocumentStore\Filter\ExistsFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - $propParts = explode('->', $prop); - $lastProp = trim(array_pop($propParts), "'"); - $parentProps = implode('->', $propParts); - return ["JSONB_EXISTS($parentProps, '$lastProp')", [], $argsCount]; - default: - throw new RuntimeException('Unsupported filter type. Got ' . get_class($filter)); - } - } + if($mapItem['alias'] === PartialSelect::MERGE_ALIAS) { + $mapItem['alias'] = self::PARTIAL_SELECT_MERGE; + } - private function propToJsonPath(string $field): string - { - if($this->useMetadataColumns && strpos($field, 'metadata.') === 0) { - return str_replace('metadata.', '', $field); + $select.= $this->propToJsonPath($mapItem['field']) . ' as "' . $mapItem['alias'] . '", '; } - return "doc->'" . str_replace('.', "'->'", $field) . "'"; - } + $select = mb_substr($select, 0, mb_strlen($select) - 2); - private function isPropFilter(Filter $filter): bool - { - switch (get_class($filter)) { - case DocumentStore\Filter\AndFilter::class: - case DocumentStore\Filter\OrFilter::class: - case DocumentStore\Filter\NotFilter::class: - return false; - default: - return true; - } + return $select; } - private function makeInClause(string $prop, array $valList, int $argsCount, bool $jsonEncode = false): array + private function transformPartialDoc(PartialSelect $partialSelect, array $selectedDoc): array { - $argList = []; - $params = \implode(",", \array_map(function ($val) use (&$argsCount, &$argList, $jsonEncode) { - $param = ":a$argsCount"; - $argList["a$argsCount"] = $jsonEncode? \json_encode($val) : $val; - $argsCount++; - return $param; - }, $valList)); + $partialDoc = []; - return ["$prop IN($params)", $argList, $argsCount]; - } + foreach ($partialSelect->fieldAliasMap() as ['field' => $field, 'alias' => $alias]) { + if($alias === PartialSelect::MERGE_ALIAS) { + if(null === $selectedDoc[self::PARTIAL_SELECT_MERGE] ?? null) { + continue; + } - private function orderByToSort(DocumentStore\OrderBy\OrderBy $orderBy): array - { - $sort = []; + $value = json_decode($selectedDoc[self::PARTIAL_SELECT_MERGE], true); + + if(!is_array($value)) { + throw new RuntimeException('Merge not possible. $merge alias was specified for field: ' . $field . ' but field value is not an array: ' . json_encode($value)); + } + + foreach ($value as $k => $v) { + $partialDoc[$k] = $v; + } - if($orderBy instanceof DocumentStore\OrderBy\AndOrder) { - /** @var DocumentStore\OrderBy\Asc|DocumentStore\OrderBy\Desc $orderByA */ - $orderByA = $orderBy->a(); - $direction = $orderByA instanceof DocumentStore\OrderBy\Asc ? 'ASC' : 'DESC'; - $prop = $this->propToJsonPath($orderByA->prop()); - $sort[] = "{$prop} $direction"; + continue; + } - $sortB = $this->orderByToSort($orderBy->b()); + $value = $selectedDoc[$alias] ?? null; - return array_merge($sort, $sortB); + if(is_string($value)) { + $value = json_decode($value, true); + } + + $keys = explode('.', $alias); + + $ref = &$partialDoc; + foreach ($keys as $i => $key) { + if(!array_key_exists($key, $ref)) { + $ref[$key] = []; + } + $ref = &$ref[$key]; + } + $ref = $value; + unset($ref); } - /** @var DocumentStore\OrderBy\Asc|DocumentStore\OrderBy\Desc $orderBy */ - $direction = $orderBy instanceof DocumentStore\OrderBy\Asc ? 'ASC' : 'DESC'; - $prop = $this->propToJsonPath($orderBy->prop()); - return ["{$prop} $direction"]; + return $partialDoc; } private function indexToSqlCmd(Index $index, string $collectionName): string @@ -701,31 +914,13 @@ private function indexToSqlCmd(Index $index, string $collectionName): string $name = $index->name() ?? ''; $cmd = <<tableName($collectionName)} +CREATE $type $name ON {$this->schemaName($collectionName)}.{$this->tableName($collectionName)} $fields; EOT; return $cmd; } - private function prepareVal($value, string $prop) - { - if(!$this->shouldJsonEncodeVal($prop)) { - return $value; - } - - return \json_encode($value); - } - - private function shouldJsonEncodeVal(string $prop): bool - { - if($this->useMetadataColumns && strpos($prop, 'metadata.') === 0) { - return false; - } - - return true; - } - private function getIndexName(Index $index): ?string { if(method_exists($index, 'name')) { @@ -751,6 +946,19 @@ private function extractFieldPartFromFieldIndex(DocumentStore\FieldIndex $fieldI private function tableName(string $collectionName): string { + if (false !== $dotPosition = strpos($collectionName, '.')) { + $collectionName = substr($collectionName, $dotPosition+1); + } + return mb_strtolower($this->tablePrefix . $collectionName); } + + private function schemaName(string $collectionName): string + { + $schemaName = 'public'; + if (false !== $dotPosition = strpos($collectionName, '.')) { + $schemaName = substr($collectionName, 0, $dotPosition); + } + return mb_strtolower($schemaName); + } } diff --git a/tests/MetadataPostgresDocumentStoreTest.php b/tests/MetadataPostgresDocumentStoreTest.php index 402d3fc..1b5b7bd 100644 --- a/tests/MetadataPostgresDocumentStoreTest.php +++ b/tests/MetadataPostgresDocumentStoreTest.php @@ -1,4 +1,12 @@ + * + * 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\DocumentStoreTest\Postgres; diff --git a/tests/PostgresDocumentStoreTest.php b/tests/PostgresDocumentStoreTest.php index fbfd40f..c46f417 100644 --- a/tests/PostgresDocumentStoreTest.php +++ b/tests/PostgresDocumentStoreTest.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -11,16 +11,31 @@ namespace EventEngine\DocumentStoreTest\Postgres; +use EventEngine\DocumentStore\Filter\AndFilter; +use EventEngine\DocumentStore\Filter\AnyFilter; use EventEngine\DocumentStore\Filter\AnyOfDocIdFilter; use EventEngine\DocumentStore\Filter\AnyOfFilter; use EventEngine\DocumentStore\Filter\DocIdFilter; +use EventEngine\DocumentStore\Filter\EqFilter; +use EventEngine\DocumentStore\Filter\GtFilter; +use EventEngine\DocumentStore\Filter\InArrayFilter; +use EventEngine\DocumentStore\Filter\LikeFilter; +use EventEngine\DocumentStore\Filter\LtFilter; use EventEngine\DocumentStore\Filter\NotFilter; +use EventEngine\DocumentStore\Filter\OrFilter; +use EventEngine\DocumentStore\OrderBy\AndOrder; +use EventEngine\DocumentStore\OrderBy\Asc; +use EventEngine\DocumentStore\OrderBy\Desc; +use EventEngine\DocumentStore\PartialSelect; use PHPUnit\Framework\TestCase; use EventEngine\DocumentStore\FieldIndex; use EventEngine\DocumentStore\Index; use EventEngine\DocumentStore\MultiFieldIndex; use EventEngine\DocumentStore\Postgres\PostgresDocumentStore; use Ramsey\Uuid\Uuid; +use function array_map; +use function array_walk; +use function iterator_to_array; class PostgresDocumentStoreTest extends TestCase { @@ -139,6 +154,153 @@ public function it_adds_collection_with_multi_field_index_unique(): void $this->assertStringStartsWith('CREATE UNIQUE INDEX', $indexes[1]['indexdef']); } + /** + * @test + */ + public function it_adds_and_updates_a_doc() + { + $collectionName = 'test_adds_and_updates_a_doc'; + $this->documentStore->addCollection($collectionName); + + $doc = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ] + ], + 'baz' => 'bat', + ]; + + $docId = Uuid::uuid4()->toString(); + $this->documentStore->addDoc($collectionName, $docId, $doc); + + $persistedDoc = $this->documentStore->getDoc($collectionName, $docId); + + $this->assertEquals($doc, $persistedDoc); + + $doc['baz'] = 'changed val'; + + $this->documentStore->updateDoc($collectionName, $docId, $doc); + + $filter = new EqFilter('baz', 'changed val'); + + $filteredDocs = $this->documentStore->findDocs($collectionName, $filter); + + $this->assertCount(1, $filteredDocs); + } + + /** + * @test + */ + public function it_updates_a_subset_of_a_doc() + { + $collectionName = 'test_updates_a_subset_of_a_doc'; + $this->documentStore->addCollection($collectionName); + + $doc = [ + 'some' => [ + 'prop' => 'foo', + 'other' => 'bar' + ], + 'baz' => 'bat', + ]; + + $docId = Uuid::uuid4()->toString(); + $this->documentStore->addDoc($collectionName, $docId, $doc); + + $this->documentStore->updateDoc($collectionName, $docId, [ + 'some' => [ + 'prop' => 'fuzz' + ] + ]); + + $filteredDocs = array_values(iterator_to_array($this->documentStore->findDocs($collectionName, new EqFilter('some.prop', 'fuzz')))); + $this->assertArrayNotHasKey('other', $filteredDocs[0]['some']); + } + + /** + * @test + */ + public function it_upserts_empty_array_doc(): void + { + $collectionName = 'test_upserts_empty_doc'; + $this->documentStore->addCollection($collectionName); + + $doc = []; + + $docId = Uuid::uuid4()->toString(); + $this->documentStore->addDoc($collectionName, $docId, $doc); + + // be aware that this will add the data as an entry to the array which is wrong, because it should be transformed to an object + $this->documentStore->upsertDoc($collectionName, $docId, [ + 'some' => [ + 'prop' => 'fuzz', + ], + ]); + + $doc = $this->documentStore->getDoc($collectionName, $docId); + $this->assertArrayHasKey('some', $doc[0], \var_export($doc, true)); + } + + /** + * @test + */ + public function it_replaces_a_doc() + { + $collectionName = 'test_replaces_a_doc'; + $this->documentStore->addCollection($collectionName); + + $doc = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ] + ], + 'baz' => 'bat', + ]; + + $docId = Uuid::uuid4()->toString(); + $this->documentStore->addDoc($collectionName, $docId, $doc); + + $doc = ['baz' => 'changed val']; + + $this->documentStore->replaceDoc($collectionName, $docId, $doc); + + $filter = new EqFilter('baz', 'changed val'); + + $filteredDocs = $this->documentStore->findDocs($collectionName, $filter); + + $this->assertCount(1, $filteredDocs); + } + + /** + * @test + */ + public function it_replaces_many() + { + $collectionName = 'test_replaces_many'; + $this->documentStore->addCollection($collectionName); + + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'foo', 'other' => ['prop' => 'bat']]]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bar', 'other' => ['prop' => 'bat']]]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bar']]); + + $doc = ['some' => ['prop' => 'fuzz']]; + $this->documentStore->replaceMany( + $collectionName, + new EqFilter('some.other.prop', 'bat'), + $doc + ); + + $filteredDocs = array_values(iterator_to_array($this->documentStore->findDocs($collectionName, new EqFilter('some.prop', 'fuzz')))); + + $this->assertCount(2, $filteredDocs); + $this->assertEquals($doc, $filteredDocs[0]); + $this->assertEquals($doc, $filteredDocs[1]); + } + /** * @test */ @@ -191,6 +353,61 @@ public function it_handles_any_of_filter() $this->assertCount(2, $filteredDocs); } + /** + * @test + */ + public function it_handles_any_of_filter_with_empty_args() + { + $collectionName = 'test_any_of_filter_with_empty_args'; + $this->documentStore->addCollection($collectionName); + + $doc1 = ["foo" => "bar"]; + $doc2 = ["foo" => "baz"]; + $doc3 = ["foo" => "bat"]; + + $docs = [$doc1, $doc2, $doc3]; + + array_walk($docs, function (array $doc) use ($collectionName) { + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), $doc); + }); + + $filteredDocs = $this->documentStore->filterDocs( + $collectionName, + new AnyOfFilter("foo", []) + ); + + $this->assertCount(0, $filteredDocs); + } + + /** + * @test + */ + public function it_uses_doc_ids_as_iterator_keys() + { + $collectionName = 'test_any_of_filter'; + $this->documentStore->addCollection($collectionName); + + $doc1 = ['id' => Uuid::uuid4()->toString(), 'doc' => ["foo" => "bar"]]; + $doc2 = ['id' => Uuid::uuid4()->toString(), 'doc' => ["foo" => "baz"]]; + $doc3 = ['id' => Uuid::uuid4()->toString(), 'doc' => ["foo" => "bat"]]; + + $docs = [$doc1, $doc2, $doc3]; + + array_walk($docs, function (array $doc) use ($collectionName) { + $this->documentStore->addDoc($collectionName, $doc['id'], $doc['doc']); + }); + + $filteredDocs = iterator_to_array($this->documentStore->findDocs( + $collectionName, + new AnyOfFilter("foo", ["bar", "bat"]) + )); + + $this->assertEquals([ + $doc1['id'] => $doc1['doc'], + $doc3['id'] => $doc3['doc'] + ], $filteredDocs); + } + /** * @test */ @@ -221,6 +438,37 @@ public function it_handles_not_any_of_filter() $this->assertSame('baz', $filteredDocs[0]['foo']); } + /** + * @test + */ + public function it_handles_not_any_of_filter_with_empty_args() + { + $collectionName = 'test_not_any_of_filter_with_empty_args'; + $this->documentStore->addCollection($collectionName); + + $doc1 = ["foo" => "bar"]; + $doc2 = ["foo" => "baz"]; + $doc3 = ["foo" => "bat"]; + + $docs = [$doc1, $doc2, $doc3]; + + array_walk($docs, function (array $doc) use ($collectionName) { + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), $doc); + }); + + $filteredDocs = $this->documentStore->filterDocs( + $collectionName, + new NotFilter(new AnyOfFilter("foo", [])) + ); + + $filteredDocs = iterator_to_array($filteredDocs); + + $this->assertCount(3, $filteredDocs); + + $this->assertSame('baz', $filteredDocs[1]['foo']); + $this->assertSame('bat', $filteredDocs[2]['foo']); + } + /** * @test */ @@ -275,6 +523,36 @@ public function it_handles_any_of_doc_id_filter() $this->assertEquals(['bar', 'baz'], $vals); } + /** + * @test + */ + public function it_handles_any_of_doc_id_filter_with_empty_args() + { + $collectionName = 'test_any_of_doc_id_filter_with_empty_args'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => 'bar']); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => 'bat']); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => 'baz']); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new AnyOfDocIdFilter([]) + )); + + $this->assertCount(0, $filteredDocs); + + $vals = array_map(function (array $doc) { + return $doc['foo']; + }, $filteredDocs); + + $this->assertEquals([], $vals); + } + /** * @test */ @@ -305,6 +583,541 @@ public function it_handles_not_any_of_id_filter() $this->assertEquals(['bat'], $vals); } + /** + * @test + */ + public function it_handles_not_any_of_id_filter_with_empty_args() + { + $collectionName = 'test_any_of_doc_id_filter_with_empty_args'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => 'bar']); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => 'bat']); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => 'baz']); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new NotFilter(new AnyOfDocIdFilter([])) + )); + + $this->assertCount(3, $filteredDocs); + + $vals = array_map(function (array $doc) { + return $doc['foo']; + }, $filteredDocs); + + $this->assertEquals(['bar', 'bat', 'baz'], $vals); + } + + /** + * @test + */ + public function it_handles_case_insensitive_like_filter() + { + $collectionName = 'test_case_insensitive_like_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => 'some BaR val']); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => 'some bAt val']); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => 'SOME baz VAL']); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new LikeFilter('foo', '%bat%') + )); + + $vals = array_map(function (array $doc) { + return $doc['foo']; + }, $filteredDocs); + + $this->assertEquals(['some bAt val'], $vals); + } + + /** + * @test + */ + public function it_handles_in_array_filter() + { + $collectionName = 'test_in_array_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => ['bar' => ['tag1', 'tag2'], 'ref' => $firstDocId]]); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => ['bar' => ['tag2', 'tag3'], 'ref' => $secondDocId]]); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => ['bar' => ['tag3', 'tag4'], 'ref' => $thirdDocId]]); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new InArrayFilter('foo.bar', 'tag3') + )); + + $this->assertCount(2, $filteredDocs); + + $refs = array_map(function (array $doc) { + return $doc['foo']['ref']; + }, $filteredDocs); + + $this->assertEquals([$secondDocId, $thirdDocId], $refs); + } + + /** + * @test + */ + public function it_handles_not_in_array_filter() + { + $collectionName = 'test_not_in_array_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => ['bar' => ['tag1', 'tag2'], 'ref' => $firstDocId]]); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => ['bar' => ['tag2', 'tag3'], 'ref' => $secondDocId]]); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => ['bar' => ['tag3', 'tag4'], 'ref' => $thirdDocId]]); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new NotFilter(new InArrayFilter('foo.bar', 'tag3')) + )); + + $this->assertCount(1, $filteredDocs); + + $refs = array_map(function (array $doc) { + return $doc['foo']['ref']; + }, $filteredDocs); + + $this->assertEquals([$firstDocId], $refs); + } + + /** + * @test + */ + public function it_handles_in_array_filter_with_object_items() + { + $collectionName = 'test_in_array_with_object_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => ['bar' => [['tag' => 'tag1', 'other' => 'data'], ['tag' => 'tag2']], 'ref' => $firstDocId]]); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => ['bar' => [['tag' => 'tag2', 'other' => 'data'], ['tag' => 'tag3']], 'ref' => $secondDocId]]); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => ['bar' => [['tag' => 'tag3', 'other' => 'data'], ['tag' => 'tag4']], 'ref' => $thirdDocId]]); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new InArrayFilter('foo.bar', ['tag' => 'tag3']) + )); + + $this->assertCount(2, $filteredDocs); + + $refs = array_map(function (array $doc) { + return $doc['foo']['ref']; + }, $filteredDocs); + + $this->assertEquals([$secondDocId, $thirdDocId], $refs); + } + + /** + * @test + */ + public function it_handles_not_filter_nested_in_and_filter() + { + $collectionName = 'test_not_filter_nested_in_and_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['foo' => ['bar' => 'bas'], 'ref' => $firstDocId]); + $this->documentStore->addDoc($collectionName, $secondDocId, ['foo' => ['bar' => 'bat'], 'ref' => $secondDocId]); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['foo' => ['bar' => 'bat'], 'ref' => $thirdDocId]); + + $filteredDocs = \iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new AndFilter( + new EqFilter('foo.bar', 'bat'), + new NotFilter( + new EqFilter('ref', $secondDocId) + ) + ) + )); + + $this->assertCount(1, $filteredDocs); + + $refs = array_map(function (array $doc) { + return $doc['ref']; + }, $filteredDocs); + + $this->assertEquals([$thirdDocId], $refs); + } + + /** + * @test + */ + public function it_retrieves_doc_ids_by_filter() + { + $collectionName = 'test_not_filter_nested_in_and_filter'; + $this->documentStore->addCollection($collectionName); + + $firstDocId = Uuid::uuid4()->toString(); + $secondDocId = Uuid::uuid4()->toString(); + $thirdDocId = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $firstDocId, ['number' => 10]); + $this->documentStore->addDoc($collectionName, $secondDocId, ['number' => 20]); + $this->documentStore->addDoc($collectionName, $thirdDocId, ['number' => 30]); + + $result = $this->documentStore->filterDocIds($collectionName, new OrFilter( + new GtFilter('number', 21), + new LtFilter('number', 19) + )); + + $this->assertEquals([$firstDocId, $thirdDocId], $result); + } + + /** + * @test + */ + public function it_counts_any_of_filter() + { + $collectionName = 'test_any_of_filter'; + $this->documentStore->addCollection($collectionName); + + $doc1 = ["foo" => "bar"]; + $doc2 = ["foo" => "baz"]; + $doc3 = ["foo" => "bat"]; + + $docs = [$doc1, $doc2, $doc3]; + + array_walk($docs, function (array $doc) use ($collectionName) { + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), $doc); + }); + + $count = $this->documentStore->countDocs( + $collectionName, + new AnyOfFilter("foo", ["bar", "bat"]) + ); + + $this->assertSame(2, $count); + } + + /** + * @test + */ + public function it_handles_order_by() + { + $collectionName = 'test_it_handles_order_by'; + $this->documentStore->addCollection($collectionName); + + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'foo']]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bar']]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bas']]); + + $filteredDocs = \array_values(\iterator_to_array($this->documentStore->findDocs( + $collectionName, + new AnyFilter(), + null, + null, + Asc::fromString('some.prop') + ))); + + $this->assertCount(3, $filteredDocs); + + $this->assertEquals( + [ + ['some' => ['prop' => 'bar']], + ['some' => ['prop' => 'bas']], + ['some' => ['prop' => 'foo']], + ], + $filteredDocs + ); + + $filteredDocs = \array_values(\iterator_to_array($this->documentStore->findDocs( + $collectionName, + new AnyFilter(), + null, + null, + Desc::fromString('some.prop') + ))); + + $this->assertCount(3, $filteredDocs); + + $this->assertEquals( + [ + ['some' => ['prop' => 'foo']], + ['some' => ['prop' => 'bas']], + ['some' => ['prop' => 'bar']], + ], + $filteredDocs + ); + } + + /** + * @test + */ + public function it_handles_and_order_by() + { + $collectionName = 'test_it_handles_order_by'; + $this->documentStore->addCollection($collectionName); + + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'foo', 'other' => ['prop' => 'bas']]]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bar', 'other' => ['prop' => 'bat']]]); + $this->documentStore->addDoc($collectionName, Uuid::uuid4()->toString(), ['some' => ['prop' => 'bar']]); + + $filteredDocs = \array_values(\iterator_to_array($this->documentStore->findDocs( + $collectionName, + new AnyFilter(), + null, + null, + AndOrder::by( + Asc::fromString('some.prop'), + Desc::fromString('some.other') + ) + ))); + + $this->assertCount(3, $filteredDocs); + + $this->assertEquals( + [ + ['some' => ['prop' => 'bar']], + ['some' => ['prop' => 'bar', 'other' => ['prop' => 'bat']]], + ['some' => ['prop' => 'foo', 'other' => ['prop' => 'bas']]], + ], + $filteredDocs + ); + } + + /** + * @test + */ + public function it_finds_partial_docs() + { + $collectionName = 'test_find_partial_docs'; + $this->documentStore->addCollection($collectionName); + + $docAId = Uuid::uuid4()->toString(); + $docA = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ] + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docAId, $docA); + + $docBId = Uuid::uuid4()->toString(); + $docB = [ + 'some' => [ + 'prop' => 'bar', + 'other' => [ + 'nested' => 43 + ], + //'baz' => 'bat', missing so should be null + ], + ]; + $this->documentStore->addDoc($collectionName, $docBId, $docB); + + $docCId = Uuid::uuid4()->toString(); + $docC = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + //'nested' => 42, missing, so should be null + 'ignoredNested' => 'value' + ] + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docCId, $docC); + + $partialSelect = new PartialSelect([ + 'some.alias' => 'some.prop', // Nested alias <- Nested field + 'magicNumber' => 'some.other.nested', // Top level alias <- Nested Field + 'baz', // Top level field, + ]); + + $result = iterator_to_array($this->documentStore->findPartialDocs($collectionName, $partialSelect, new AnyFilter())); + + $this->assertEquals([ + 'some' => [ + 'alias' => 'foo', + ], + 'magicNumber' => 42, + 'baz' => 'bat', + ], $result[$docAId]); + + $this->assertEquals([ + 'some' => [ + 'alias' => 'bar', + ], + 'magicNumber' => 43, + 'baz' => null, + ], $result[$docBId]); + + $this->assertEquals([ + 'some' => [ + 'alias' => 'foo', + ], + 'magicNumber' => null, + 'baz' => 'bat', + ], $result[$docCId]); + } + + /** + * @test + */ + public function it_gets_partial_doc_by_id() + { + $collectionName = 'test_get_partial_doc'; + $this->documentStore->addCollection($collectionName); + + $docAId = Uuid::uuid4()->toString(); + $docA = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ] + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docAId, $docA); + + $docBId = Uuid::uuid4()->toString(); + $docB = [ + 'some' => [ + 'prop' => 'bar', + 'other' => [ + 'nested' => 43 + ], + //'baz' => 'bat', missing so should be null + ], + ]; + $this->documentStore->addDoc($collectionName, $docBId, $docB); + + $docCId = Uuid::uuid4()->toString(); + $docC = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + //'nested' => 42, missing, so should be null + 'ignoredNested' => 'value' + ] + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docCId, $docC); + + $partialSelect = new PartialSelect([ + 'some.alias' => 'some.prop', // Nested alias <- Nested field + 'magicNumber' => 'some.other.nested', // Top level alias <- Nested Field + 'baz', // Top level field, + ]); + + $partialDocA = $this->documentStore->getPartialDoc($collectionName, $partialSelect, $docAId); + + $this->assertEquals([ + 'some' => [ + 'alias' => 'foo', + ], + 'magicNumber' => 42, + 'baz' => 'bat', + ], $partialDocA); + + $partialDocD = $this->documentStore->getPartialDoc($collectionName, $partialSelect, Uuid::uuid4()->toString()); + + $this->assertNull($partialDocD); + } + + /** + * @test + */ + public function it_applies_merge_alias_for_nested_fields_if_specified() + { + $collectionName = 'test_applies_merge_alias'; + $this->documentStore->addCollection($collectionName); + + $docAId = Uuid::uuid4()->toString(); + $docA = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ] + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docAId, $docA); + + $docBId = Uuid::uuid4()->toString(); + $docB = [ + 'differentTopLevel' => [ + 'prop' => 'bar', + 'other' => [ + 'nested' => 43 + ], + ], + 'baz' => 'bat', + ]; + $this->documentStore->addDoc($collectionName, $docBId, $docB); + + $docCId = Uuid::uuid4()->toString(); + $docC = [ + 'some' => [ + 'prop' => 'foo', + 'other' => [ + 'nested' => 43 + ], + ], + //'baz' => 'bat', missing top level + ]; + $this->documentStore->addDoc($collectionName, $docCId, $docC); + + $partialSelect = new PartialSelect([ + '$merge' => 'some', // $merge alias <- Nested field + 'baz', // Top level field + ]); + + $result = iterator_to_array($this->documentStore->findPartialDocs($collectionName, $partialSelect, new AnyFilter())); + + $this->assertEquals([ + 'prop' => 'foo', + 'other' => [ + 'nested' => 42 + ], + 'baz' => 'bat' + ], $result[$docAId]); + + $this->assertEquals([ + 'baz' => 'bat', + ], $result[$docBId]); + + $this->assertEquals([ + 'prop' => 'foo', + 'other' => [ + 'nested' => 43 + ], + 'baz' => null + ], $result[$docCId]); + } + private function getIndexes(string $collectionName): array { return TestUtil::getIndexes($this->connection, self::TABLE_PREFIX.$collectionName); diff --git a/tests/SchemedPostgresDocumentStoreTest.php b/tests/SchemedPostgresDocumentStoreTest.php new file mode 100644 index 0000000..180377d --- /dev/null +++ b/tests/SchemedPostgresDocumentStoreTest.php @@ -0,0 +1,60 @@ + + * + * 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\DocumentStoreTest\Postgres; + +use EventEngine\DocumentStore\Filter\AnyOfDocIdFilter; +use EventEngine\DocumentStore\Filter\AnyOfFilter; +use EventEngine\DocumentStore\Filter\DocIdFilter; +use EventEngine\DocumentStore\Filter\NotFilter; +use PHPUnit\Framework\TestCase; +use EventEngine\DocumentStore\FieldIndex; +use EventEngine\DocumentStore\Index; +use EventEngine\DocumentStore\MultiFieldIndex; +use EventEngine\DocumentStore\Postgres\PostgresDocumentStore; +use Ramsey\Uuid\Uuid; + +class SchemedPostgresDocumentStoreTest extends TestCase +{ + private CONST TABLE_PREFIX = 'test_'; + private CONST SCHEMA = 'test.'; + + /** + * @var PostgresDocumentStore + */ + protected $documentStore; + + /** + * @var \PDO + */ + protected $connection; + + protected function setUp(): void + { + $this->connection = TestUtil::getConnection(); + $this->documentStore = new PostgresDocumentStore($this->connection, self::TABLE_PREFIX); + } + + protected function tearDown(): void + { + TestUtil::tearDownDatabase(); + } + + /** + * @test + */ + public function it_adds_collection_with_schema(): void + { + $this->documentStore->addCollection(self::SCHEMA . 'test'); + $this->assertFalse($this->documentStore->hasCollection('test')); + $this->assertTrue($this->documentStore->hasCollection(self::SCHEMA . 'test')); + } +} diff --git a/tests/TestUtil.php b/tests/TestUtil.php index fe263d0..9efe706 100644 --- a/tests/TestUtil.php +++ b/tests/TestUtil.php @@ -1,7 +1,7 @@ + * (c) 2019-2021 prooph software GmbH * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -86,6 +86,7 @@ public static function tearDownDatabase(): void $connection = self::getConnection(); $statement = $connection->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\';'); $connection->exec('DROP SCHEMA IF EXISTS prooph CASCADE'); + $connection->exec('DROP SCHEMA IF EXISTS test CASCADE'); $statement->execute(); $tables = $statement->fetchAll(PDO::FETCH_COLUMN);