diff --git a/composer.json b/composer.json index ecfca2e..8470564 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,12 @@ ], "require": { "php": "^7.2 || ^8.0", + "ext-json": "*", "ext-pdo": "*", "event-engine/php-persistence": "^0.9" }, "require-dev": { - "infection/infection": "^0.15.3", + "infection/infection": "^0.26.6", "malukenho/docheader": "^0.1.8", "phpspec/prophecy": "^1.12.1", "phpstan/phpstan": "^0.12.48", @@ -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/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/PostgresDocumentStore.php b/src/PostgresDocumentStore.php index ddbbfc2..39e0925 100644 --- a/src/PostgresDocumentStore.php +++ b/src/PostgresDocumentStore.php @@ -16,8 +16,9 @@ use EventEngine\DocumentStore\Index; use EventEngine\DocumentStore\OrderBy\OrderBy; use EventEngine\DocumentStore\PartialSelect; -use EventEngine\DocumentStore\Postgres\Exception\InvalidArgumentException; use EventEngine\DocumentStore\Postgres\Exception\RuntimeException; +use EventEngine\DocumentStore\Postgres\Filter\PostgresFilterProcessor; +use EventEngine\DocumentStore\Postgres\Filter\FilterProcessor; use EventEngine\Util\VariableType; use function implode; @@ -37,24 +38,35 @@ final class PostgresDocumentStore implements DocumentStore\DocumentStore */ private $connection; + /** + * @var FilterProcessor + */ + private $filterProcessor; + 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, bool $transactional = true, - bool $useMetadataColumns = false + bool $useMetadataColumns = false, + FilterProcessor $filterProcessor = 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 !== $tablePrefix) { $this->tablePrefix = $tablePrefix; } @@ -384,9 +396,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 = []; @@ -477,7 +491,9 @@ public function replaceDoc(string $collectionName, string $docId, array $doc): v */ public function replaceMany(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" : ''; @@ -534,7 +550,9 @@ 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" : ''; @@ -603,7 +621,9 @@ public function getPartialDoc(string $collectionName, PartialSelect $partialSele */ public function filterDocs(string $collectionName, Filter $filter, int $skip = null, int $limit = null, OrderBy $orderBy = null): \Traversable { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); $where = $filterStr ? "WHERE $filterStr" : ''; @@ -634,7 +654,9 @@ public function filterDocs(string $collectionName, Filter $filter, int $skip = n */ public function findDocs(string $collectionName, Filter $filter, int $skip = null, int $limit = null, OrderBy $orderBy = null): \Traversable { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); $where = $filterStr ? "WHERE $filterStr" : ''; @@ -662,7 +684,9 @@ public function findDocs(string $collectionName, Filter $filter, int $skip = nul public function findPartialDocs(string $collectionName, PartialSelect $partialSelect, Filter $filter, int $skip = null, int $limit = null, OrderBy $orderBy = null): \Traversable { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); $select = $this->makeSelect($partialSelect); @@ -698,7 +722,9 @@ public function findPartialDocs(string $collectionName, PartialSelect $partialSe */ public function filterDocIds(string $collectionName, Filter $filter): array { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $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}"; @@ -721,7 +747,9 @@ public function filterDocIds(string $collectionName, Filter $filter): array */ public function countDocs(string $collectionName, Filter $filter): int { - [$filterStr, $args] = $this->filterToWhereClause($filter); + $filterClause = $this->filterProcessor->process($filter); + $filterStr = $filterClause->clause(); + $args = $filterClause->args(); $where = $filterStr? "WHERE $filterStr" : ''; @@ -756,105 +784,6 @@ private function transactional(callable $callback) } } - private function filterToWhereClause(Filter $filter, $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->filterToWhereClause($filter->aFilter(), $argsCount); - [$filterB, $argsB, $argsCount] = $this->filterToWhereClause($filter->bFilter(), $argsCount); - return ["($filterA AND $filterB)", array_merge($argsA, $argsB), $argsCount]; - } - - 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]; - } - - 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->filterToWhereClause($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 propToJsonPath(string $field): string { if($this->useMetadataColumns && strpos($field, 'metadata.') === 0) { @@ -864,34 +793,6 @@ private function propToJsonPath(string $field): string 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 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 makeSelect(PartialSelect $partialSelect): string { $select = 'id as "'.self::PARTIAL_SELECT_DOC_ID.'", '; @@ -1016,24 +917,6 @@ private function indexToSqlCmd(Index $index, string $collectionName): string 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')) { @@ -1066,12 +949,12 @@ private function tableName(string $collectionName): string 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); - } + private function schemaName(string $collectionName): string + { + $schemaName = 'public'; + if (false !== $dotPosition = strpos($collectionName, '.')) { + $schemaName = substr($collectionName, 0, $dotPosition); + } + return mb_strtolower($schemaName); + } }