2021-05-26 15:50:35 +02:00
< ? php
/**
2024-05-23 09:26:56 +02:00
* SPDX - FileCopyrightText : 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX - License - Identifier : AGPL - 3.0 - or - later
2021-05-26 15:50:35 +02:00
*/
namespace OC\Files\Cache ;
use OCP\DB\QueryBuilder\IQueryBuilder ;
use OCP\Files\IMimeTypeLoader ;
use OCP\Files\Search\ISearchBinaryOperator ;
use OCP\Files\Search\ISearchComparison ;
use OCP\Files\Search\ISearchOperator ;
use OCP\Files\Search\ISearchOrder ;
2025-01-20 13:45:33 +01:00
use OCP\FilesMetadata\IFilesMetadataManager ;
2023-11-13 22:25:22 -01:00
use OCP\FilesMetadata\IMetadataQuery ;
2021-05-26 15:50:35 +02:00
/**
* Tools for transforming search queries into database queries
2024-02-06 10:59:17 +01:00
*
* @ psalm - import - type ParamSingleValue from ISearchComparison
* @ psalm - import - type ParamValue from ISearchComparison
2021-05-26 15:50:35 +02:00
*/
class SearchBuilder {
2024-02-06 10:59:17 +01:00
/** @var array<string, string> */
2021-05-26 15:50:35 +02:00
protected static $searchOperatorMap = [
ISearchComparison :: COMPARE_LIKE => 'iLike' ,
2021-08-17 13:50:13 +02:00
ISearchComparison :: COMPARE_LIKE_CASE_SENSITIVE => 'like' ,
2021-05-26 15:50:35 +02:00
ISearchComparison :: COMPARE_EQUAL => 'eq' ,
ISearchComparison :: COMPARE_GREATER_THAN => 'gt' ,
ISearchComparison :: COMPARE_GREATER_THAN_EQUAL => 'gte' ,
ISearchComparison :: COMPARE_LESS_THAN => 'lt' ,
ISearchComparison :: COMPARE_LESS_THAN_EQUAL => 'lte' ,
2023-11-15 20:02:28 -01:00
ISearchComparison :: COMPARE_DEFINED => 'isNotNull' ,
2023-09-21 13:49:16 +02:00
ISearchComparison :: COMPARE_IN => 'in' ,
2021-05-26 15:50:35 +02:00
];
2024-02-06 10:59:17 +01:00
/** @var array<string, string> */
2021-05-26 15:50:35 +02:00
protected static $searchOperatorNegativeMap = [
ISearchComparison :: COMPARE_LIKE => 'notLike' ,
2021-08-17 13:50:13 +02:00
ISearchComparison :: COMPARE_LIKE_CASE_SENSITIVE => 'notLike' ,
2021-05-26 15:50:35 +02:00
ISearchComparison :: COMPARE_EQUAL => 'neq' ,
ISearchComparison :: COMPARE_GREATER_THAN => 'lte' ,
ISearchComparison :: COMPARE_GREATER_THAN_EQUAL => 'lt' ,
ISearchComparison :: COMPARE_LESS_THAN => 'gte' ,
2022-08-16 17:59:02 +08:00
ISearchComparison :: COMPARE_LESS_THAN_EQUAL => 'gt' ,
2023-11-15 20:02:28 -01:00
ISearchComparison :: COMPARE_DEFINED => 'isNull' ,
2023-09-21 13:49:16 +02:00
ISearchComparison :: COMPARE_IN => 'notIn' ,
];
2024-02-06 10:59:17 +01:00
/** @var array<string, string> */
2023-09-21 13:49:16 +02:00
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' ,
];
2024-09-18 18:51:00 +02:00
/** @var array<string, int|string> */
2023-09-21 13:49:16 +02:00
protected static $paramTypeMap = [
'string' => IQueryBuilder :: PARAM_STR ,
'integer' => IQueryBuilder :: PARAM_INT ,
'boolean' => IQueryBuilder :: PARAM_BOOL ,
];
2024-02-06 10:59:17 +01:00
/** @var array<string, int> */
2023-09-21 13:49:16 +02:00
protected static $paramArrayTypeMap = [
'string' => IQueryBuilder :: PARAM_STR_ARRAY ,
'integer' => IQueryBuilder :: PARAM_INT_ARRAY ,
'boolean' => IQueryBuilder :: PARAM_INT_ARRAY ,
2021-05-26 15:50:35 +02:00
];
public const TAG_FAVORITE = '_$!<Favorite>!$_' ;
public function __construct (
2025-01-20 13:45:33 +01:00
private IMimeTypeLoader $mimetypeLoader ,
private IFilesMetadataManager $filesMetadataManager ,
2021-05-26 15:50:35 +02:00
) {
}
/**
2023-06-15 22:46:04 +02:00
* @ return string []
2021-05-26 15:50:35 +02:00
*/
2023-06-15 22:46:04 +02:00
public function extractRequestedFields ( ISearchOperator $operator ) : array {
2021-05-26 15:50:35 +02:00
if ( $operator instanceof ISearchBinaryOperator ) {
2023-06-15 22:46:04 +02:00
return array_reduce ( $operator -> getArguments (), function ( array $fields , ISearchOperator $operator ) {
return array_unique ( array_merge ( $fields , $this -> extractRequestedFields ( $operator )));
}, []);
2023-11-07 12:43:01 -01:00
} elseif ( $operator instanceof ISearchComparison && ! $operator -> getExtra ()) {
2023-06-15 22:46:04 +02:00
return [ $operator -> getField ()];
2021-05-26 15:50:35 +02:00
}
2023-06-15 22:46:04 +02:00
return [];
2021-05-26 15:50:35 +02:00
}
/**
* @ param IQueryBuilder $builder
2021-08-23 15:01:03 +02:00
* @ param ISearchOperator [] $operators
2021-05-26 15:50:35 +02:00
*/
2023-11-07 00:21:29 -01:00
public function searchOperatorArrayToDBExprArray (
IQueryBuilder $builder ,
array $operators ,
? IMetadataQuery $metadataQuery = null ,
) {
return array_filter ( array_map ( function ( $operator ) use ( $builder , $metadataQuery ) {
return $this -> searchOperatorToDBExpr ( $builder , $operator , $metadataQuery );
2021-05-26 15:50:35 +02:00
}, $operators ));
}
2023-11-07 00:21:29 -01:00
public function searchOperatorToDBExpr (
IQueryBuilder $builder ,
ISearchOperator $operator ,
? IMetadataQuery $metadataQuery = null ,
) {
2021-05-26 15:50:35 +02:00
$expr = $builder -> expr ();
2021-08-23 15:01:03 +02:00
2021-05-26 15:50:35 +02:00
if ( $operator instanceof ISearchBinaryOperator ) {
if ( count ( $operator -> getArguments ()) === 0 ) {
return null ;
}
switch ( $operator -> getType ()) {
case ISearchBinaryOperator :: OPERATOR_NOT :
$negativeOperator = $operator -> getArguments ()[ 0 ];
if ( $negativeOperator instanceof ISearchComparison ) {
2023-11-07 00:21:29 -01:00
return $this -> searchComparisonToDBExpr ( $builder , $negativeOperator , self :: $searchOperatorNegativeMap , $metadataQuery );
2021-05-26 15:50:35 +02:00
} else {
throw new \InvalidArgumentException ( 'Binary operators inside "not" is not supported' );
}
2023-01-20 11:45:08 +01:00
// no break
2021-05-26 15:50:35 +02:00
case ISearchBinaryOperator :: OPERATOR_AND :
2023-11-07 00:21:29 -01:00
return call_user_func_array ([ $expr , 'andX' ], $this -> searchOperatorArrayToDBExprArray ( $builder , $operator -> getArguments (), $metadataQuery ));
2021-05-26 15:50:35 +02:00
case ISearchBinaryOperator :: OPERATOR_OR :
2023-11-07 00:21:29 -01:00
return call_user_func_array ([ $expr , 'orX' ], $this -> searchOperatorArrayToDBExprArray ( $builder , $operator -> getArguments (), $metadataQuery ));
2021-05-26 15:50:35 +02:00
default :
throw new \InvalidArgumentException ( 'Invalid operator type: ' . $operator -> getType ());
}
} elseif ( $operator instanceof ISearchComparison ) {
2023-11-07 00:21:29 -01:00
return $this -> searchComparisonToDBExpr ( $builder , $operator , self :: $searchOperatorMap , $metadataQuery );
2021-05-26 15:50:35 +02:00
} else {
throw new \InvalidArgumentException ( 'Invalid operator type: ' . get_class ( $operator ));
}
}
2023-11-07 00:21:29 -01:00
private function searchComparisonToDBExpr (
IQueryBuilder $builder ,
ISearchComparison $comparison ,
array $operatorMap ,
? IMetadataQuery $metadataQuery = null ,
) {
if ( $comparison -> getExtra ()) {
2023-09-21 13:49:16 +02:00
[ $field , $value , $type , $paramType ] = $this -> getExtraOperatorField ( $comparison , $metadataQuery );
2023-11-07 00:21:29 -01:00
} else {
2023-09-21 13:49:16 +02:00
[ $field , $value , $type , $paramType ] = $this -> getOperatorFieldAndValue ( $comparison );
2023-11-07 00:21:29 -01:00
}
2021-05-26 15:50:35 +02:00
if ( isset ( $operatorMap [ $type ])) {
$queryOperator = $operatorMap [ $type ];
2023-09-21 13:49:16 +02:00
return $builder -> expr () -> $queryOperator ( $field , $this -> getParameterForValue ( $builder , $value , $paramType ));
2021-05-26 15:50:35 +02:00
} else {
throw new \InvalidArgumentException ( 'Invalid operator type: ' . $comparison -> getType ());
}
}
2023-09-21 13:49:16 +02:00
/**
* @ param ISearchComparison $operator
2024-02-06 10:59:17 +01:00
* @ return list { string , ParamValue , string , string }
2023-09-21 13:49:16 +02:00
*/
private function getOperatorFieldAndValue ( ISearchComparison $operator ) : array {
2023-11-07 00:21:29 -01:00
$this -> validateComparison ( $operator );
2021-05-26 15:50:35 +02:00
$field = $operator -> getField ();
$value = $operator -> getValue ();
$type = $operator -> getType ();
2023-09-21 13:49:16 +02:00
$pathEqHash = $operator -> getQueryHint ( ISearchComparison :: HINT_PATH_EQ_HASH , true );
return $this -> getOperatorFieldAndValueInner ( $field , $value , $type , $pathEqHash );
}
2023-11-07 00:21:29 -01:00
2023-09-21 13:49:16 +02:00
/**
* @ param string $field
2024-02-06 10:59:17 +01:00
* @ param ParamValue $value
2023-09-21 13:49:16 +02:00
* @ param string $type
2024-02-06 10:59:17 +01:00
* @ return list { string , ParamValue , string , string }
2023-09-21 13:49:16 +02:00
*/
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 ) {
2024-02-06 10:59:17 +01:00
/** @var ParamSingleValue $arrayValue */
2023-09-21 13:49:16 +02:00
[ $arrayField , $arrayValue ] = $this -> getOperatorFieldAndValueInner ( $field , $arrayValue , ISearchComparison :: COMPARE_EQUAL , $pathEqHash );
$resultField = $arrayField ;
$values [] = $arrayValue ;
}
return [ $resultField , $values , ISearchComparison :: COMPARE_IN , $paramType ];
}
2021-05-26 15:50:35 +02:00
if ( $field === 'mimetype' ) {
$value = ( string ) $value ;
2023-09-21 13:49:16 +02:00
if ( $type === ISearchComparison :: COMPARE_EQUAL ) {
2023-10-19 16:27:07 +02:00
$value = $this -> mimetypeLoader -> getId ( $value );
2023-09-21 13:49:16 +02:00
} elseif ( $type === ISearchComparison :: COMPARE_LIKE ) {
2021-05-26 15:50:35 +02:00
// transform "mimetype='foo/%'" to "mimepart='foo'"
if ( preg_match ( '|(.+)/%|' , $value , $matches )) {
$field = 'mimepart' ;
2023-10-19 16:27:07 +02:00
$value = $this -> mimetypeLoader -> getId ( $matches [ 1 ]);
2021-05-26 15:50:35 +02:00
$type = ISearchComparison :: COMPARE_EQUAL ;
2023-05-15 15:17:19 +03:30
} elseif ( str_contains ( $value , '%' )) {
2021-05-26 15:50:35 +02:00
throw new \InvalidArgumentException ( 'Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported' );
} else {
$field = 'mimetype' ;
2023-10-19 16:27:07 +02:00
$value = $this -> mimetypeLoader -> getId ( $value );
2021-05-26 15:50:35 +02:00
$type = ISearchComparison :: COMPARE_EQUAL ;
}
}
} elseif ( $field === 'favorite' ) {
$field = 'tag.category' ;
$value = self :: TAG_FAVORITE ;
2023-09-21 13:49:16 +02:00
$paramType = 'string' ;
2021-04-28 19:07:15 +02:00
} elseif ( $field === 'name' ) {
$field = 'file.name' ;
2021-05-26 15:50:35 +02:00
} elseif ( $field === 'tagname' ) {
$field = 'tag.category' ;
2021-04-28 19:07:15 +02:00
} elseif ( $field === 'systemtag' ) {
$field = 'systemtag.name' ;
2021-05-26 15:50:35 +02:00
} elseif ( $field === 'fileid' ) {
$field = 'file.fileid' ;
2023-09-21 13:49:16 +02:00
} elseif ( $field === 'path' && $type === ISearchComparison :: COMPARE_EQUAL && $pathEqHash ) {
2021-05-26 15:50:35 +02:00
$field = 'path_hash' ;
$value = md5 (( string ) $value );
2023-09-25 15:07:29 +02:00
} elseif ( $field === 'owner' ) {
$field = 'uid_owner' ;
2021-05-26 15:50:35 +02:00
}
2023-09-21 13:49:16 +02:00
return [ $field , $value , $type , $paramType ];
2021-05-26 15:50:35 +02:00
}
private function validateComparison ( ISearchComparison $operator ) {
$comparisons = [
2023-09-21 13:49:16 +02:00
'mimetype' => [ 'eq' , 'like' , 'in' ],
2021-05-26 15:50:35 +02:00
'mtime' => [ 'eq' , 'gt' , 'lt' , 'gte' , 'lte' ],
2023-09-21 13:49:16 +02:00
'name' => [ 'eq' , 'like' , 'clike' , 'in' ],
'path' => [ 'eq' , 'like' , 'clike' , 'in' ],
2021-05-26 15:50:35 +02:00
'size' => [ 'eq' , 'gt' , 'lt' , 'gte' , 'lte' ],
'tagname' => [ 'eq' , 'like' ],
2021-04-28 19:07:15 +02:00
'systemtag' => [ 'eq' , 'like' ],
2021-05-26 15:50:35 +02:00
'favorite' => [ 'eq' ],
2023-09-21 13:49:16 +02:00
'fileid' => [ 'eq' , 'in' ],
'storage' => [ 'eq' , 'in' ],
2023-09-25 15:07:29 +02:00
'share_with' => [ 'eq' ],
'share_type' => [ 'eq' ],
'owner' => [ 'eq' ],
2021-05-26 15:50:35 +02:00
];
2023-09-21 13:49:16 +02:00
if ( ! isset ( self :: $fieldTypes [ $operator -> getField ()])) {
2021-05-26 15:50:35 +02:00
throw new \InvalidArgumentException ( 'Unsupported comparison field ' . $operator -> getField ());
}
2023-09-21 13:49:16 +02:00
$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 ());
}
2021-05-26 15:50:35 +02:00
}
if ( ! in_array ( $operator -> getType (), $comparisons [ $operator -> getField ()])) {
throw new \InvalidArgumentException ( 'Unsupported comparison for field ' . $operator -> getField () . ': ' . $operator -> getType ());
}
}
2023-11-07 00:21:29 -01:00
private function getExtraOperatorField ( ISearchComparison $operator , IMetadataQuery $metadataQuery ) : array {
$field = $operator -> getField ();
$value = $operator -> getValue ();
$type = $operator -> getType ();
2025-01-20 13:45:33 +01:00
$knownMetadata = $this -> filesMetadataManager -> getKnownMetadata ();
$isIndex = $knownMetadata -> isIndex ( $field );
$paramType = $knownMetadata -> getType ( $field ) === 'int' ? 'integer' : 'string' ;
if ( ! $isIndex ) {
throw new \InvalidArgumentException ( 'Cannot search non indexed metadata key' );
}
2023-11-07 00:21:29 -01:00
switch ( $operator -> getExtra ()) {
case IMetadataQuery :: EXTRA :
$metadataQuery -> joinIndex ( $field ); // join index table if not joined yet
$field = $metadataQuery -> getMetadataValueField ( $field );
break ;
default :
throw new \InvalidArgumentException ( 'Invalid extra type: ' . $operator -> getExtra ());
}
2023-09-21 13:49:16 +02:00
return [ $field , $value , $type , $paramType ];
2023-11-07 00:21:29 -01:00
}
2023-09-21 13:49:16 +02:00
private function getParameterForValue ( IQueryBuilder $builder , $value , string $paramType ) {
2021-05-26 15:50:35 +02:00
if ( $value instanceof \DateTime ) {
$value = $value -> getTimestamp ();
}
2023-09-21 13:49:16 +02:00
if ( is_array ( $value )) {
$type = self :: $paramArrayTypeMap [ $paramType ];
2021-05-26 15:50:35 +02:00
} else {
2023-09-21 13:49:16 +02:00
$type = self :: $paramTypeMap [ $paramType ];
2021-05-26 15:50:35 +02:00
}
return $builder -> createNamedParameter ( $value , $type );
}
/**
* @ param IQueryBuilder $query
* @ param ISearchOrder [] $orders
2023-11-07 00:21:29 -01:00
* @ param IMetadataQuery | null $metadataQuery
2021-05-26 15:50:35 +02:00
*/
2023-11-07 00:21:29 -01:00
public function addSearchOrdersToQuery ( IQueryBuilder $query , array $orders , ? IMetadataQuery $metadataQuery = null ) : void {
2021-05-26 15:50:35 +02:00
foreach ( $orders as $order ) {
$field = $order -> getField ();
2023-11-07 00:21:29 -01:00
switch ( $order -> getExtra ()) {
case IMetadataQuery :: EXTRA :
$metadataQuery -> joinIndex ( $field ); // join index table if not joined yet
$field = $metadataQuery -> getMetadataValueField ( $order -> getField ());
break ;
2022-04-22 13:31:34 +02:00
2023-11-07 00:21:29 -01:00
default :
if ( $field === 'fileid' ) {
$field = 'file.fileid' ;
}
2022-04-22 13:31:34 +02:00
2023-11-07 00:21:29 -01:00
// Mysql really likes to pick an index for sorting if it can't fully satisfy the where
// filter with an index, since search queries pretty much never are fully filtered by index
// mysql often picks an index for sorting instead of the much more useful index for filtering.
//
// By changing the order by to an expression, mysql isn't smart enough to see that it could still
// use the index, so it instead picks an index for the filtering
if ( $field === 'mtime' ) {
$field = $query -> func () -> add ( $field , $query -> createNamedParameter ( 0 ));
}
}
2021-05-26 15:50:35 +02:00
$query -> addOrderBy ( $field , $order -> getDirection ());
}
}
}