Merge pull request #40555 from nextcloud/query-optimize-distribute

optimize query pattern used by storage filter
This commit is contained in:
Robin Appelman 2024-02-16 11:24:35 +01:00 committed by GitHub
commit bb87232882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 877 additions and 54 deletions

View file

@ -1,4 +1,3 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1406,9 +1406,15 @@ return array(
'OC\\Files\\ObjectStore\\Swift' => $baseDir . '/lib/private/Files/ObjectStore/Swift.php',
'OC\\Files\\ObjectStore\\SwiftFactory' => $baseDir . '/lib/private/Files/ObjectStore/SwiftFactory.php',
'OC\\Files\\ObjectStore\\SwiftV2CachingAuthService' => $baseDir . '/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php',
'OC\\Files\\Search\\QueryOptimizer\\FlattenNestedBool' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/FlattenNestedBool.php',
'OC\\Files\\Search\\QueryOptimizer\\FlattenSingleArgumentBinaryOperation' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/FlattenSingleArgumentBinaryOperation.php',
'OC\\Files\\Search\\QueryOptimizer\\MergeDistributiveOperations' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/MergeDistributiveOperations.php',
'OC\\Files\\Search\\QueryOptimizer\\OrEqualsToIn' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/OrEqualsToIn.php',
'OC\\Files\\Search\\QueryOptimizer\\PathPrefixOptimizer' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php',
'OC\\Files\\Search\\QueryOptimizer\\QueryOptimizer' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php',
'OC\\Files\\Search\\QueryOptimizer\\QueryOptimizerStep' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php',
'OC\\Files\\Search\\QueryOptimizer\\ReplacingOptimizerStep' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/ReplacingOptimizerStep.php',
'OC\\Files\\Search\\QueryOptimizer\\SplitLargeIn' => $baseDir . '/lib/private/Files/Search/QueryOptimizer/SplitLargeIn.php',
'OC\\Files\\Search\\SearchBinaryOperator' => $baseDir . '/lib/private/Files/Search/SearchBinaryOperator.php',
'OC\\Files\\Search\\SearchComparison' => $baseDir . '/lib/private/Files/Search/SearchComparison.php',
'OC\\Files\\Search\\SearchOrder' => $baseDir . '/lib/private/Files/Search/SearchOrder.php',

View file

@ -1439,9 +1439,15 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Files\\ObjectStore\\Swift' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/Swift.php',
'OC\\Files\\ObjectStore\\SwiftFactory' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/SwiftFactory.php',
'OC\\Files\\ObjectStore\\SwiftV2CachingAuthService' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php',
'OC\\Files\\Search\\QueryOptimizer\\FlattenNestedBool' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/FlattenNestedBool.php',
'OC\\Files\\Search\\QueryOptimizer\\FlattenSingleArgumentBinaryOperation' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/FlattenSingleArgumentBinaryOperation.php',
'OC\\Files\\Search\\QueryOptimizer\\MergeDistributiveOperations' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/MergeDistributiveOperations.php',
'OC\\Files\\Search\\QueryOptimizer\\OrEqualsToIn' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/OrEqualsToIn.php',
'OC\\Files\\Search\\QueryOptimizer\\PathPrefixOptimizer' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php',
'OC\\Files\\Search\\QueryOptimizer\\QueryOptimizer' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php',
'OC\\Files\\Search\\QueryOptimizer\\QueryOptimizerStep' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php',
'OC\\Files\\Search\\QueryOptimizer\\ReplacingOptimizerStep' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/ReplacingOptimizerStep.php',
'OC\\Files\\Search\\QueryOptimizer\\SplitLargeIn' => __DIR__ . '/../../..' . '/lib/private/Files/Search/QueryOptimizer/SplitLargeIn.php',
'OC\\Files\\Search\\SearchBinaryOperator' => __DIR__ . '/../../..' . '/lib/private/Files/Search/SearchBinaryOperator.php',
'OC\\Files\\Search\\SearchComparison' => __DIR__ . '/../../..' . '/lib/private/Files/Search/SearchComparison.php',
'OC\\Files\\Search\\SearchOrder' => __DIR__ . '/../../..' . '/lib/private/Files/Search/SearchOrder.php',

View file

@ -37,8 +37,12 @@ use OCP\FilesMetadata\IMetadataQuery;
/**
* Tools for transforming search queries into database queries
*
* @psalm-import-type ParamSingleValue from ISearchComparison
* @psalm-import-type ParamValue from ISearchComparison
*/
class SearchBuilder {
/** @var array<string, string> */
protected static $searchOperatorMap = [
ISearchComparison::COMPARE_LIKE => 'iLike',
ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like',
@ -48,8 +52,10 @@ class SearchBuilder {
ISearchComparison::COMPARE_LESS_THAN => 'lt',
ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte',
ISearchComparison::COMPARE_DEFINED => 'isNotNull',
ISearchComparison::COMPARE_IN => 'in',
];
/** @var array<string, string> */
protected static $searchOperatorNegativeMap = [
ISearchComparison::COMPARE_LIKE => 'notLike',
ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'notLike',
@ -59,6 +65,38 @@ class SearchBuilder {
ISearchComparison::COMPARE_LESS_THAN => 'gte',
ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'gt',
ISearchComparison::COMPARE_DEFINED => 'isNull',
ISearchComparison::COMPARE_IN => 'notIn',
];
/** @var array<string, string> */
protected static $fieldTypes = [
'mimetype' => 'string',
'mtime' => 'integer',
'name' => 'string',
'path' => 'string',
'size' => 'integer',
'tagname' => 'string',
'systemtag' => 'string',
'favorite' => 'boolean',
'fileid' => 'integer',
'storage' => 'integer',
'share_with' => 'string',
'share_type' => 'integer',
'owner' => 'string',
];
/** @var array<string, int> */
protected static $paramTypeMap = [
'string' => IQueryBuilder::PARAM_STR,
'integer' => IQueryBuilder::PARAM_INT,
'boolean' => IQueryBuilder::PARAM_BOOL,
];
/** @var array<string, int> */
protected static $paramArrayTypeMap = [
'string' => IQueryBuilder::PARAM_STR_ARRAY,
'integer' => IQueryBuilder::PARAM_INT_ARRAY,
'boolean' => IQueryBuilder::PARAM_INT_ARRAY,
];
public const TAG_FAVORITE = '_$!<Favorite>!$_';
@ -142,31 +180,56 @@ class SearchBuilder {
?IMetadataQuery $metadataQuery = null
) {
if ($comparison->getExtra()) {
[$field, $value, $type] = $this->getExtraOperatorField($comparison, $metadataQuery);
[$field, $value, $type, $paramType] = $this->getExtraOperatorField($comparison, $metadataQuery);
} else {
[$field, $value, $type] = $this->getOperatorFieldAndValue($comparison);
[$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($comparison);
}
if (isset($operatorMap[$type])) {
$queryOperator = $operatorMap[$type];
return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value));
return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value, $paramType));
} else {
throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType());
}
}
private function getOperatorFieldAndValue(ISearchComparison $operator) {
/**
* @param ISearchComparison $operator
* @return list{string, ParamValue, string, string}
*/
private function getOperatorFieldAndValue(ISearchComparison $operator): array {
$this->validateComparison($operator);
$field = $operator->getField();
$value = $operator->getValue();
$type = $operator->getType();
$pathEqHash = $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true);
return $this->getOperatorFieldAndValueInner($field, $value, $type, $pathEqHash);
}
/**
* @param string $field
* @param ParamValue $value
* @param string $type
* @return list{string, ParamValue, string, string}
*/
private function getOperatorFieldAndValueInner(string $field, mixed $value, string $type, bool $pathEqHash): array {
$paramType = self::$fieldTypes[$field];
if ($type === ISearchComparison::COMPARE_IN) {
$resultField = $field;
$values = [];
foreach ($value as $arrayValue) {
/** @var ParamSingleValue $arrayValue */
[$arrayField, $arrayValue] = $this->getOperatorFieldAndValueInner($field, $arrayValue, ISearchComparison::COMPARE_EQUAL, $pathEqHash);
$resultField = $arrayField;
$values[] = $arrayValue;
}
return [$resultField, $values, ISearchComparison::COMPARE_IN, $paramType];
}
if ($field === 'mimetype') {
$value = (string)$value;
if ($operator->getType() === ISearchComparison::COMPARE_EQUAL) {
if ($type === ISearchComparison::COMPARE_EQUAL) {
$value = (int)$this->mimetypeLoader->getId($value);
} elseif ($operator->getType() === ISearchComparison::COMPARE_LIKE) {
} elseif ($type === ISearchComparison::COMPARE_LIKE) {
// transform "mimetype='foo/%'" to "mimepart='foo'"
if (preg_match('|(.+)/%|', $value, $matches)) {
$field = 'mimepart';
@ -183,6 +246,7 @@ class SearchBuilder {
} elseif ($field === 'favorite') {
$field = 'tag.category';
$value = self::TAG_FAVORITE;
$paramType = 'string';
} elseif ($field === 'name') {
$field = 'file.name';
} elseif ($field === 'tagname') {
@ -191,53 +255,49 @@ class SearchBuilder {
$field = 'systemtag.name';
} elseif ($field === 'fileid') {
$field = 'file.fileid';
} elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true)) {
} elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $pathEqHash) {
$field = 'path_hash';
$value = md5((string)$value);
} elseif ($field === 'owner') {
$field = 'uid_owner';
}
return [$field, $value, $type];
return [$field, $value, $type, $paramType];
}
private function validateComparison(ISearchComparison $operator) {
$types = [
'mimetype' => 'string',
'mtime' => 'integer',
'name' => 'string',
'path' => 'string',
'size' => 'integer',
'tagname' => 'string',
'systemtag' => 'string',
'favorite' => 'boolean',
'fileid' => 'integer',
'storage' => 'integer',
'share_with' => 'string',
'share_type' => 'integer',
'owner' => 'string',
];
$comparisons = [
'mimetype' => ['eq', 'like'],
'mimetype' => ['eq', 'like', 'in'],
'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'],
'name' => ['eq', 'like', 'clike'],
'path' => ['eq', 'like', 'clike'],
'name' => ['eq', 'like', 'clike', 'in'],
'path' => ['eq', 'like', 'clike', 'in'],
'size' => ['eq', 'gt', 'lt', 'gte', 'lte'],
'tagname' => ['eq', 'like'],
'systemtag' => ['eq', 'like'],
'favorite' => ['eq'],
'fileid' => ['eq'],
'storage' => ['eq'],
'fileid' => ['eq', 'in'],
'storage' => ['eq', 'in'],
'share_with' => ['eq'],
'share_type' => ['eq'],
'owner' => ['eq'],
];
if (!isset($types[$operator->getField()])) {
if (!isset(self::$fieldTypes[$operator->getField()])) {
throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField());
}
$type = $types[$operator->getField()];
if (gettype($operator->getValue()) !== $type) {
throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
$type = self::$fieldTypes[$operator->getField()];
if ($operator->getType() === ISearchComparison::COMPARE_IN) {
if (!is_array($operator->getValue())) {
throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
}
foreach ($operator->getValue() as $arrayValue) {
if (gettype($arrayValue) !== $type) {
throw new \InvalidArgumentException('Invalid type in array for field ' . $operator->getField());
}
}
} else {
if (gettype($operator->getValue()) !== $type) {
throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField());
}
}
if (!in_array($operator->getType(), $comparisons[$operator->getField()])) {
throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType());
@ -246,6 +306,7 @@ class SearchBuilder {
private function getExtraOperatorField(ISearchComparison $operator, IMetadataQuery $metadataQuery): array {
$paramType = self::$fieldTypes[$operator->getField()];
$field = $operator->getField();
$value = $operator->getValue();
$type = $operator->getType();
@ -259,17 +320,17 @@ class SearchBuilder {
throw new \InvalidArgumentException('Invalid extra type: ' . $operator->getExtra());
}
return [$field, $value, $type];
return [$field, $value, $type, $paramType];
}
private function getParameterForValue(IQueryBuilder $builder, $value) {
private function getParameterForValue(IQueryBuilder $builder, $value, string $paramType) {
if ($value instanceof \DateTime) {
$value = $value->getTimestamp();
}
if (is_numeric($value)) {
$type = IQueryBuilder::PARAM_INT;
if (is_array($value)) {
$type = self::$paramArrayTypeMap[$paramType];
} else {
$type = IQueryBuilder::PARAM_STR;
$type = self::$paramTypeMap[$paramType];
}
return $builder->createNamedParameter($value, $type);
}

View file

@ -0,0 +1,29 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchOperator;
class FlattenNestedBool extends QueryOptimizerStep {
public function processOperator(ISearchOperator &$operator) {
if (
$operator instanceof SearchBinaryOperator && (
$operator->getType() === ISearchBinaryOperator::OPERATOR_OR ||
$operator->getType() === ISearchBinaryOperator::OPERATOR_AND
)
) {
$newArguments = [];
foreach ($operator->getArguments() as $oldArgument) {
if ($oldArgument instanceof SearchBinaryOperator && $oldArgument->getType() === $operator->getType()) {
$newArguments = array_merge($newArguments, $oldArgument->getArguments());
} else {
$newArguments[] = $oldArgument;
}
}
$operator->setArguments($newArguments);
}
parent::processOperator($operator);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchOperator;
/**
* replace single argument AND and OR operations with their single argument
*/
class FlattenSingleArgumentBinaryOperation extends ReplacingOptimizerStep {
public function processOperator(ISearchOperator &$operator): bool {
parent::processOperator($operator);
if (
$operator instanceof ISearchBinaryOperator &&
count($operator->getArguments()) === 1 &&
(
$operator->getType() === ISearchBinaryOperator::OPERATOR_OR ||
$operator->getType() === ISearchBinaryOperator::OPERATOR_AND
)
) {
$operator = $operator->getArguments()[0];
return true;
}
return false;
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchOperator;
/**
* Attempt to transform
*
* (A AND B) OR (A AND C) OR (A AND D AND E) into A AND (B OR C OR (D AND E))
*
* This is always valid because logical 'AND' and 'OR' are distributive[1].
*
* [1]: https://en.wikipedia.org/wiki/Distributive_property
*/
class MergeDistributiveOperations extends ReplacingOptimizerStep {
public function processOperator(ISearchOperator &$operator): bool {
if (
$operator instanceof SearchBinaryOperator &&
$this->isAllSameBinaryOperation($operator->getArguments())
) {
// either 'AND' or 'OR'
$topLevelType = $operator->getType();
// split the arguments into groups that share a first argument
// (we already know that all arguments are binary operators with at least 1 child)
$groups = $this->groupBinaryOperatorsByChild($operator->getArguments(), 0);
$outerOperations = array_map(function (array $operators) use ($topLevelType) {
// no common operations, no need to change anything
if (count($operators) === 1) {
return $operators[0];
}
/** @var ISearchBinaryOperator $firstArgument */
$firstArgument = $operators[0];
// we already checked that all arguments have the same type, so this type applies for all, either 'AND' or 'OR'
$innerType = $firstArgument->getType();
// the common operation we move out ('A' from the example)
$extractedLeftHand = $firstArgument->getArguments()[0];
// for each argument we remove the extracted operation to get the leftovers ('B', 'C' and '(D AND E)' in the example)
// note that we leave them inside the "inner" binary operation for when the "inner" operation contained more than two parts
// in the (common) case where the "inner" operation only has 1 item left it will be cleaned up in a follow up step
$rightHandArguments = array_map(function (ISearchOperator $inner) {
/** @var ISearchBinaryOperator $inner */
$arguments = $inner->getArguments();
array_shift($arguments);
if (count($arguments) === 1) {
return $arguments[0];
}
return new SearchBinaryOperator($inner->getType(), $arguments);
}, $operators);
// combine the extracted operation ('A') with the remaining bit ('(B OR C OR (D AND E))')
// note that because of how distribution work, we use the "outer" type "inside" and the "inside" type "outside".
$extractedRightHand = new SearchBinaryOperator($topLevelType, $rightHandArguments);
return new SearchBinaryOperator(
$innerType,
[$extractedLeftHand, $extractedRightHand]
);
}, $groups);
// combine all groups again
$operator = new SearchBinaryOperator($topLevelType, $outerOperations);
parent::processOperator($operator);
return true;
}
return parent::processOperator($operator);
}
/**
* Check that a list of operators is all the same type of (non-empty) binary operators
*
* @param ISearchOperator[] $operators
* @return bool
* @psalm-assert-if-true SearchBinaryOperator[] $operators
*/
private function isAllSameBinaryOperation(array $operators): bool {
$operation = null;
foreach ($operators as $operator) {
if (!$operator instanceof SearchBinaryOperator) {
return false;
}
if (!$operator->getArguments()) {
return false;
}
if ($operation === null) {
$operation = $operator->getType();
} else {
if ($operation !== $operator->getType()) {
return false;
}
}
}
return true;
}
/**
* Group a list of binary search operators that have a common argument
*
* @param SearchBinaryOperator[] $operators
* @return SearchBinaryOperator[][]
*/
private function groupBinaryOperatorsByChild(array $operators, int $index = 0): array {
$result = [];
foreach ($operators as $operator) {
/** @var SearchBinaryOperator|SearchComparison $child */
$child = $operator->getArguments()[$index];
$childKey = (string) $child;
$result[$childKey][] = $operator;
}
return array_values($result);
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use OCP\Files\Search\ISearchOperator;
/**
* transform (field == A OR field == B ...) into field IN (A, B, ...)
*/
class OrEqualsToIn extends ReplacingOptimizerStep {
public function processOperator(ISearchOperator &$operator): bool {
if (
$operator instanceof ISearchBinaryOperator &&
$operator->getType() === ISearchBinaryOperator::OPERATOR_OR
) {
$groups = $this->groupEqualsComparisonsByField($operator->getArguments());
$newParts = array_map(function (array $group) {
if (count($group) > 1) {
// because of the logic from `groupEqualsComparisonsByField` we now that group is all comparisons on the same field
/** @var ISearchComparison[] $group */
$field = $group[0]->getField();
$values = array_map(function (ISearchComparison $comparison) {
/** @var string|integer|bool|\DateTime $value */
$value = $comparison->getValue();
return $value;
}, $group);
$in = new SearchComparison(ISearchComparison::COMPARE_IN, $field, $values);
$pathEqHash = array_reduce($group, function ($pathEqHash, ISearchComparison $comparison) {
return $comparison->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true) && $pathEqHash;
}, true);
$in->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, $pathEqHash);
return $in;
} else {
return $group[0];
}
}, $groups);
if (count($newParts) === 1) {
$operator = $newParts[0];
} else {
$operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $newParts);
}
parent::processOperator($operator);
return true;
}
parent::processOperator($operator);
return false;
}
/**
* Non-equals operators are put in a separate group for each
*
* @param ISearchOperator[] $operators
* @return ISearchOperator[][]
*/
private function groupEqualsComparisonsByField(array $operators): array {
$result = [];
foreach ($operators as $operator) {
if ($operator instanceof ISearchComparison && $operator->getType() === ISearchComparison::COMPARE_EQUAL) {
$result[$operator->getField()][] = $operator;
} else {
$result[] = [$operator];
}
}
return array_values($result);
}
}

View file

@ -29,15 +29,20 @@ class QueryOptimizer {
/** @var QueryOptimizerStep[] */
private $steps = [];
public function __construct(
PathPrefixOptimizer $pathPrefixOptimizer
) {
public function __construct() {
// note that the order here is relevant
$this->steps = [
$pathPrefixOptimizer
new PathPrefixOptimizer(),
new MergeDistributiveOperations(),
new FlattenSingleArgumentBinaryOperation(),
new FlattenNestedBool(),
new OrEqualsToIn(),
new FlattenNestedBool(),
new SplitLargeIn(),
];
}
public function processOperator(ISearchOperator $operator) {
public function processOperator(ISearchOperator &$operator) {
foreach ($this->steps as $step) {
$step->inspectOperator($operator);
}

View file

@ -0,0 +1,31 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OCP\Files\Search\ISearchOperator;
/**
* Optimizer step that can replace the $operator altogether instead of just modifying it
* These steps need some extra logic to properly replace the arguments of binary operators
*/
class ReplacingOptimizerStep extends QueryOptimizerStep {
/**
* Allow optimizer steps to modify query operators
*
* Returns true if the reference $operator points to a new value
*/
public function processOperator(ISearchOperator &$operator): bool {
if ($operator instanceof SearchBinaryOperator) {
$modified = false;
$arguments = $operator->getArguments();
foreach ($arguments as &$argument) {
$modified = $modified || $this->processOperator($argument);
}
if ($modified) {
$operator->setArguments($arguments);
}
}
return false;
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace OC\Files\Search\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use OCP\Files\Search\ISearchOperator;
/**
* transform IN (1000+ element) into (IN (1000 elements) OR IN(...))
*/
class SplitLargeIn extends ReplacingOptimizerStep {
public function processOperator(ISearchOperator &$operator): bool {
if (
$operator instanceof ISearchComparison &&
$operator->getType() === ISearchComparison::COMPARE_IN &&
count($operator->getValue()) > 1000
) {
$chunks = array_chunk($operator->getValue(), 1000);
$chunkComparisons = array_map(function (array $values) use ($operator) {
return new SearchComparison(ISearchComparison::COMPARE_IN, $operator->getField(), $values);
}, $chunks);
$operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $chunkComparisons);
return true;
}
parent::processOperator($operator);
return false;
}
}

View file

@ -28,7 +28,7 @@ use OCP\Files\Search\ISearchOperator;
class SearchBinaryOperator implements ISearchBinaryOperator {
/** @var string */
private $type;
/** @var ISearchOperator[] */
/** @var (SearchBinaryOperator|SearchComparison)[] */
private $arguments;
private $hints = [];
@ -36,7 +36,7 @@ class SearchBinaryOperator implements ISearchBinaryOperator {
* SearchBinaryOperator constructor.
*
* @param string $type
* @param ISearchOperator[] $arguments
* @param (SearchBinaryOperator|SearchComparison)[] $arguments
*/
public function __construct($type, array $arguments) {
$this->type = $type;
@ -57,6 +57,14 @@ class SearchBinaryOperator implements ISearchBinaryOperator {
return $this->arguments;
}
/**
* @param ISearchOperator[] $arguments
* @return void
*/
public function setArguments(array $arguments): void {
$this->arguments = $arguments;
}
public function getQueryHint(string $name, $default) {
return $this->hints[$name] ?? $default;
}
@ -64,4 +72,11 @@ class SearchBinaryOperator implements ISearchBinaryOperator {
public function setQueryHint(string $name, $value): void {
$this->hints[$name] = $value;
}
public function __toString(): string {
if ($this->type === ISearchBinaryOperator::OPERATOR_NOT) {
return '(not ' . $this->arguments[0] . ')';
}
return '(' . implode(' ' . $this->type . ' ', $this->arguments) . ')';
}
}

View file

@ -27,13 +27,17 @@ namespace OC\Files\Search;
use OCP\Files\Search\ISearchComparison;
/**
* @psalm-import-type ParamValue from ISearchComparison
*/
class SearchComparison implements ISearchComparison {
private array $hints = [];
public function __construct(
private string $type,
private string $field,
private \DateTime|int|string|bool $value,
/** @var ParamValue $value */
private \DateTime|int|string|bool|array $value,
private string $extra = ''
) {
}
@ -52,10 +56,7 @@ class SearchComparison implements ISearchComparison {
return $this->field;
}
/**
* @return \DateTime|int|string|bool
*/
public function getValue(): string|int|bool|\DateTime {
public function getValue(): string|int|bool|\DateTime|array {
return $this->value;
}
@ -78,4 +79,8 @@ class SearchComparison implements ISearchComparison {
public static function escapeLikeParameter(string $param): string {
return addcslashes($param, '\\_%');
}
public function __toString(): string {
return $this->field . ' ' . $this->type . ' ' . json_encode($this->value);
}
}

View file

@ -26,6 +26,9 @@ namespace OCP\Files\Search;
/**
* @since 12.0.0
*
* @psalm-type ParamSingleValue = \DateTime|int|string|bool
* @psalm-type ParamValue = ParamSingleValue|list<ParamSingleValue>
*/
interface ISearchComparison extends ISearchOperator {
/**
@ -67,6 +70,11 @@ interface ISearchComparison extends ISearchOperator {
* @since 28.0.0
*/
public const COMPARE_DEFINED = 'is-defined';
/**
* @since 29.0.0
*/
public const COMPARE_IN = 'in';
/**
* @since 23.0.0
@ -102,8 +110,8 @@ interface ISearchComparison extends ISearchOperator {
/**
* Get the value to compare the field with
*
* @return string|integer|bool|\DateTime
* @return ParamValue
* @since 12.0.0
*/
public function getValue(): string|int|bool|\DateTime;
public function getValue(): string|int|bool|\DateTime|array;
}

View file

@ -154,6 +154,7 @@ class SearchBuilderTest extends TestCase {
[new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', 'foo%'), [0, 1]],
[new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', 'image/jpg'), [0]],
[new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', 'image/%'), [0, 1]],
[new SearchComparison(ISearchComparison::COMPARE_IN, 'mimetype', ['image/jpg', 'image/png']), [0, 1]],
[new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'size', 50),
new SearchComparison(ISearchComparison::COMPARE_LESS_THAN, 'mtime', 125)

View file

@ -0,0 +1,45 @@
<?php
namespace Test\Files\Search\QueryOptimizer;
use OC\Files\Search\QueryOptimizer\QueryOptimizer;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use Test\TestCase;
class CombinedTests extends TestCase {
private QueryOptimizer $optimizer;
protected function setUp(): void {
parent::setUp();
$this->optimizer = new QueryOptimizer();
}
public function testBasicOrOfAnds() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 1 and path eq "bar") or (storage eq 1 and path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->assertEquals('(storage eq 1 and path in ["foo","bar","asd"])', $operator->__toString());
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Test\Files\Search\QueryOptimizer;
use OC\Files\Search\QueryOptimizer\FlattenNestedBool;
use OC\Files\Search\QueryOptimizer\FlattenSingleArgumentBinaryOperation;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use Test\TestCase;
class FlattenNestedBoolTest extends TestCase {
private $optimizer;
private $simplifier;
protected function setUp(): void {
parent::setUp();
$this->optimizer = new FlattenNestedBool();
$this->simplifier = new FlattenSingleArgumentBinaryOperation();
}
public function testOrs() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
);
$this->assertEquals('(path eq "foo" or (path eq "bar" or path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(path eq "foo" or path eq "bar" or path eq "asd")', $operator->__toString());
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Test\Files\Search\QueryOptimizer;
use OC\Files\Search\QueryOptimizer\FlattenSingleArgumentBinaryOperation;
use OC\Files\Search\QueryOptimizer\MergeDistributiveOperations;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use Test\TestCase;
class MergeDistributiveOperationsTest extends TestCase {
private $optimizer;
private $simplifier;
protected function setUp(): void {
parent::setUp();
$this->optimizer = new MergeDistributiveOperations();
$this->simplifier = new FlattenSingleArgumentBinaryOperation();
}
public function testBasicOrOfAnds() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 1 and path eq "bar") or (storage eq 1 and path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(storage eq 1 and (path eq "foo" or path eq "bar" or path eq "asd"))', $operator->__toString());
}
public function testDontTouchIfNotSame() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 2),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 3),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 2 and path eq "bar") or (storage eq 3 and path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 2 and path eq "bar") or (storage eq 3 and path eq "asd"))', $operator->__toString());
}
public function testMergePartial() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 2),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 1 and path eq "bar") or (storage eq 2 and path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('((storage eq 1 and (path eq "foo" or path eq "bar")) or (storage eq 2 and path eq "asd"))', $operator->__toString());
}
public function testOptimizeInside() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_AND,
[
new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
])
]
),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "mimetype", "text")
]
);
$this->assertEquals('(((storage eq 1 and path eq "foo") or (storage eq 1 and path eq "bar") or (storage eq 1 and path eq "asd")) and mimetype eq "text")', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('((storage eq 1 and (path eq "foo" or path eq "bar" or path eq "asd")) and mimetype eq "text")', $operator->__toString());
}
public function testMoveInnerOperations() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]),
new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "storage", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN, "size", "100"),
])
]
);
$this->assertEquals('((storage eq 1 and path eq "foo") or (storage eq 1 and path eq "bar") or (storage eq 1 and path eq "asd" and size gt "100"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(storage eq 1 and (path eq "foo" or path eq "bar" or (path eq "asd" and size gt "100")))', $operator->__toString());
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace Test\Files\Search\QueryOptimizer;
use OC\Files\Search\QueryOptimizer\FlattenSingleArgumentBinaryOperation;
use OC\Files\Search\QueryOptimizer\OrEqualsToIn;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use Test\TestCase;
class OrEqualsToInTest extends TestCase {
private $optimizer;
private $simplifier;
protected function setUp(): void {
parent::setUp();
$this->optimizer = new OrEqualsToIn();
$this->simplifier = new FlattenSingleArgumentBinaryOperation();
}
public function testOrs() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
]
);
$this->assertEquals('(path eq "foo" or path eq "bar" or path eq "asd")', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('path in ["foo","bar","asd"]', $operator->__toString());
}
public function testOrsMultipleFields() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "fileid", 1),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "fileid", 2),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "mimetype", "asd"),
]
);
$this->assertEquals('(path eq "foo" or path eq "bar" or fileid eq 1 or fileid eq 2 or mimetype eq "asd")', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(path in ["foo","bar"] or fileid in [1,2] or mimetype eq "asd")', $operator->__toString());
}
public function testPreserveHints() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
]
);
foreach ($operator->getArguments() as $argument) {
$argument->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, false);
}
$this->assertEquals('(path eq "foo" or path eq "bar" or path eq "asd")', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('path in ["foo","bar","asd"]', $operator->__toString());
$this->assertEquals(false, $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true));
}
public function testOrSomeEq() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchComparison(ISearchComparison::COMPARE_LIKE, "path", "foo%"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
]
);
$this->assertEquals('(path eq "foo" or path like "foo%" or path eq "bar")', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(path in ["foo","bar"] or path like "foo%")', $operator->__toString());
}
public function testOrsInside() {
$operator = new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_AND,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "mimetype", "text"),
new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
[
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "foo"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "bar"),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, "path", "asd"),
]
)
]
);
$this->assertEquals('(mimetype eq "text" and (path eq "foo" or path eq "bar" or path eq "asd"))', $operator->__toString());
$this->optimizer->processOperator($operator);
$this->simplifier->processOperator($operator);
$this->assertEquals('(mimetype eq "text" and path in ["foo","bar","asd"])', $operator->__toString());
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Test\Files\Search;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OC\Files\Search\SearchQuery;
use OC\Files\Storage\Temporary;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use Test\TestCase;
/**
* @group DB
*/
class SearchIntegrationTest extends TestCase {
private $cache;
private $storage;
protected function setUp(): void {
parent::setUp();
$this->storage = new Temporary([]);
$this->cache = $this->storage->getCache();
$this->storage->getScanner()->scan('');
}
public function testThousandAndOneFilters() {
$id = $this->cache->put("file10", ['size' => 1, 'mtime' => 50, 'mimetype' => 'foo/folder']);
$comparisons = [];
for($i = 1; $i <= 1001; $i++) {
$comparisons[] = new SearchComparison(ISearchComparison::COMPARE_EQUAL, "name", "file$i");
}
$operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $comparisons);
$query = new SearchQuery($operator, 10, 0, []);
$results = $this->cache->searchQuery($query);
$this->assertCount(1, $results);
$this->assertEquals($id, $results[0]->getId());
}
}