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 6e724f5..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, true); - case DocumentStore\Filter\EqFilter::class: - /** @var DocumentStore\Filter\EqFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop = :a$argsCount", ["a$argsCount" => json_encode($filter->val())], ++$argsCount]; - case DocumentStore\Filter\GtFilter::class: - /** @var DocumentStore\Filter\GtFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop > :a$argsCount", ["a$argsCount" => json_encode($filter->val())], ++$argsCount]; - case DocumentStore\Filter\GteFilter::class: - /** @var DocumentStore\Filter\GteFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop >= :a$argsCount", ["a$argsCount" => json_encode($filter->val())], ++$argsCount]; - case DocumentStore\Filter\LtFilter::class: - /** @var DocumentStore\Filter\LtFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop < :a$argsCount", ["a$argsCount" => json_encode($filter->val())], ++$argsCount]; - case DocumentStore\Filter\LteFilter::class: - /** @var DocumentStore\Filter\LteFilter $filter */ - $prop = $this->propToJsonPath($filter->prop()); - return ["$prop <= :a$argsCount", ["a$argsCount" => json_encode($filter->val())], ++$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" => json_encode($filter->val())], ++$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)); + } - 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"; + foreach ($value as $k => $v) { + $partialDoc[$k] = $v; + } + + 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,7 +914,7 @@ 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; @@ -733,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 d4abfe5..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; @@ -315,6 +323,52 @@ public function it_fills_and_queries_metadata_column() $this->assertEquals('v4', $docs[0]['state']['name']); } + /** + * @test + */ + public function it_fills_and_queries_metadata_varchar_column() + { + $collectionName = 'test_col_query_name_meta'; + + $index1 = new MetadataColumnIndex( + FieldIndex::namedIndexForField('meta_field_idx_name', 'metadata.name'), + new Column('name VARCHAR(10)') + ); + + $this->documentStore->addCollection($collectionName, $index1); + + $docId1 = Uuid::uuid4()->toString(); + $docId2 = Uuid::uuid4()->toString(); + $docId3 = Uuid::uuid4()->toString(); + + $this->documentStore->addDoc($collectionName, $docId1, ['state' => ['name' => 'v1'], 'metadata' => ['name' => 'v1']]); + $this->documentStore->addDoc($collectionName, $docId2, ['state' => ['name' => 'v2'], 'metadata' => ['name' => 'v2']]); + $this->documentStore->addDoc($collectionName, $docId3, ['state' => ['name' => 'v3'], 'metadata' => ['name' => 'v3']]); + + $prefix = self::TABLE_PREFIX; + $stmt = "SELECT * FROM $prefix{$collectionName} WHERE name = 'v2';"; + $stmt = $this->connection->prepare($stmt); + $stmt->execute(); + $docs = $stmt->fetchAll(); + + $this->assertCount(1, $docs); + $this->assertEquals($docId2, $docs[0]['id']); + $this->assertEquals(['state' => ['name' => 'v2']], json_decode($docs[0]['doc'], true)); + $this->assertEquals('v2', $docs[0]['name']); + + $docs = iterator_to_array($this->documentStore->filterDocs( + $collectionName, + new GteFilter('metadata.name', 'v2'), + null, + null, + Desc::byProp('metadata.name') + )); + + $this->assertCount(2, $docs); + $this->assertEquals('v3', $docs[0]['state']['name']); + $this->assertEquals('v2', $docs[1]['state']['name']); + } + private function getIndexes(string $collectionName): array { return TestUtil::getIndexes($this->connection, self::TABLE_PREFIX.$collectionName); 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); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy