diff options
-rw-r--r-- | autoload.php | 1 | ||||
-rw-r--r-- | includes/ExternalLinks/LinkFilter.php | 22 | ||||
-rw-r--r-- | includes/libs/rdbms/database/IReadableDatabase.php | 4 | ||||
-rw-r--r-- | includes/libs/rdbms/expression/AndExpressionGroup.php | 4 | ||||
-rw-r--r-- | includes/libs/rdbms/expression/Expression.php | 20 | ||||
-rw-r--r-- | includes/libs/rdbms/expression/IExpression.php | 6 | ||||
-rw-r--r-- | includes/libs/rdbms/expression/LikeValue.php | 62 | ||||
-rw-r--r-- | includes/libs/rdbms/expression/OrExpressionGroup.php | 4 | ||||
-rw-r--r-- | includes/libs/rdbms/platform/SQLPlatform.php | 22 | ||||
-rw-r--r-- | includes/page/PageArchive.php | 14 | ||||
-rw-r--r-- | tests/phpunit/includes/ExternalLinks/LinkFilterTest.php | 54 |
11 files changed, 145 insertions, 68 deletions
diff --git a/autoload.php b/autoload.php index 07d713e28710..9d544f1b725d 100644 --- a/autoload.php +++ b/autoload.php @@ -3115,6 +3115,7 @@ $wgAutoloadLocalClasses = [ 'Wikimedia\\Rdbms\\LBFactorySimple' => __DIR__ . '/includes/libs/rdbms/lbfactory/LBFactorySimple.php', 'Wikimedia\\Rdbms\\LBFactorySingle' => __DIR__ . '/includes/libs/rdbms/lbfactory/LBFactorySingle.php', 'Wikimedia\\Rdbms\\LikeMatch' => __DIR__ . '/includes/libs/rdbms/encasing/LikeMatch.php', + 'Wikimedia\\Rdbms\\LikeValue' => __DIR__ . '/includes/libs/rdbms/expression/LikeValue.php', 'Wikimedia\\Rdbms\\LoadBalancer' => __DIR__ . '/includes/libs/rdbms/loadbalancer/LoadBalancer.php', 'Wikimedia\\Rdbms\\LoadBalancerDisabled' => __DIR__ . '/includes/libs/rdbms/loadbalancer/LoadBalancerDisabled.php', 'Wikimedia\\Rdbms\\LoadBalancerSingle' => __DIR__ . '/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php', diff --git a/includes/ExternalLinks/LinkFilter.php b/includes/ExternalLinks/LinkFilter.php index ca994602c170..7ed7afb75617 100644 --- a/includes/ExternalLinks/LinkFilter.php +++ b/includes/ExternalLinks/LinkFilter.php @@ -26,8 +26,11 @@ use MediaWiki\MediaWikiServices; use StringUtils; use TextContent; use Wikimedia\IPUtils; +use Wikimedia\Rdbms\AndExpressionGroup; +use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\LikeMatch; -use Wikimedia\Rdbms\Platform\ISQLPlatform; +use Wikimedia\Rdbms\LikeValue; +use Wikimedia\Rdbms\OrExpressionGroup; /** * Utilities for formatting and querying the externallinks table. @@ -361,15 +364,16 @@ class LinkFilter { if ( $options['oneWildcard'] && $likePath[0] != '/' ) { $thisDomainConditions[] = $db->expr( 'el_to_domain_index', '=', $index1 ); } else { - $thisDomainConditions[] = "el_to_domain_index" . $db->buildLike( $index1, $db->anyString() ); + $thisDomainConditions[] = $db->expr( + 'el_to_domain_index', + IExpression::LIKE, + new LikeValue( $index1, $db->anyString() ) + ); } foreach ( $domainGaps[$index1] ?? [] as $from => $to ) { - $thisDomainConditions[] = $db->makeList( [ - $db->expr( 'el_id', '<', $from ), - $db->expr( 'el_id', '>', $to ), - ], ISQLPlatform::LIST_OR ); + $thisDomainConditions[] = $db->expr( 'el_id', '<', $from )->or( 'el_id', '>', $to ); } - $domainConditions[] = $db->makeList( $thisDomainConditions, ISQLPlatform::LIST_AND ); + $domainConditions[] = new AndExpressionGroup( ...$thisDomainConditions ); } if ( !$domainConditions ) { @@ -383,8 +387,8 @@ class LinkFilter { $index2 = implode( '', $trimmedlikePath ); return [ - $db->makeList( $domainConditions, ISQLPlatform::LIST_OR ), - "el_to_path" . $db->buildLike( $index2, $db->anyString() ), + new OrExpressionGroup( ...$domainConditions ), + $db->expr( 'el_to_path', IExpression::LIKE, new LikeValue( $index2, $db->anyString() ) ), ]; } diff --git a/includes/libs/rdbms/database/IReadableDatabase.php b/includes/libs/rdbms/database/IReadableDatabase.php index 5daa0520a2fd..a8e8fea0d3aa 100644 --- a/includes/libs/rdbms/database/IReadableDatabase.php +++ b/includes/libs/rdbms/database/IReadableDatabase.php @@ -671,9 +671,9 @@ interface IReadableDatabase extends ISQLPlatform, DbQuoter, IDatabaseFlags { * @since 1.42 * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql * @return Expression */ diff --git a/includes/libs/rdbms/expression/AndExpressionGroup.php b/includes/libs/rdbms/expression/AndExpressionGroup.php index 6fa27c334298..58fe4146a81e 100644 --- a/includes/libs/rdbms/expression/AndExpressionGroup.php +++ b/includes/libs/rdbms/expression/AndExpressionGroup.php @@ -15,9 +15,9 @@ class AndExpressionGroup extends ExpressionGroup { /** * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql */ public function and( string $field, string $op, $value ): AndExpressionGroup { diff --git a/includes/libs/rdbms/expression/Expression.php b/includes/libs/rdbms/expression/Expression.php index 9c4c4e4100dd..6279e7686fb4 100644 --- a/includes/libs/rdbms/expression/Expression.php +++ b/includes/libs/rdbms/expression/Expression.php @@ -19,11 +19,12 @@ class Expression implements IExpression { /** * Store an expression * + * @internal Outside of rdbms, Use IReadableDatabase::expr() to create an expression object. * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql */ public function __construct( string $field, string $op, $value ) { @@ -40,6 +41,10 @@ class Expression implements IExpression { if ( is_array( $value ) && in_array( null, $value, true ) ) { throw new InvalidArgumentException( "NULL can't be in the array of values" ); } + + if ( $op === IExpression::LIKE && !( $value instanceof LikeValue ) ) { + throw new InvalidArgumentException( "Value for like expression must be of LikeValue type" ); + } $this->field = $field; $this->op = $op; $this->value = $value; @@ -48,9 +53,9 @@ class Expression implements IExpression { /** * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql */ public function and( string $field, string $op, $value ): AndExpressionGroup { @@ -61,9 +66,9 @@ class Expression implements IExpression { /** * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql */ public function or( string $field, string $op, $value ): OrExpressionGroup { @@ -108,6 +113,9 @@ class Expression implements IExpression { throw new LogicException( "Operator $this->op can't take null as value" ); } } + if ( $this->op === IExpression::LIKE && $this->value instanceof LikeValue ) { + return $this->field . ' ' . $this->op . ' ' . $this->value->toSql( $dbQuoter ); + } return $this->field . ' ' . $this->op . ' ' . $dbQuoter->addQuotes( $this->value ); } diff --git a/includes/libs/rdbms/expression/IExpression.php b/includes/libs/rdbms/expression/IExpression.php index a8d8610b71d4..d333dc306488 100644 --- a/includes/libs/rdbms/expression/IExpression.php +++ b/includes/libs/rdbms/expression/IExpression.php @@ -5,11 +5,13 @@ namespace Wikimedia\Rdbms; use Wikimedia\Rdbms\Database\DbQuoter; /** - * @internal + * @since 1.42 */ interface IExpression { - public const ACCEPTABLE_OPERATORS = [ '>', '<', '!=', '=', '>=', '<=' ]; + public const ACCEPTABLE_OPERATORS = [ '>', '<', '!=', '=', '>=', '<=', self::LIKE ]; + + public const LIKE = 'LIKE'; /** * Return SQL for execution. diff --git a/includes/libs/rdbms/expression/LikeValue.php b/includes/libs/rdbms/expression/LikeValue.php new file mode 100644 index 000000000000..8ba454a338d8 --- /dev/null +++ b/includes/libs/rdbms/expression/LikeValue.php @@ -0,0 +1,62 @@ +<?php + +namespace Wikimedia\Rdbms; + +use InvalidArgumentException; +use Wikimedia\Rdbms\Database\DbQuoter; + +/** + * Content of like value + * + * @newable + * @since 1.42 + */ +class LikeValue { + private array $values = []; + + public function __construct( $value, ...$values ) { + if ( !is_string( $value ) && !( $value instanceof LikeMatch ) ) { + throw new InvalidArgumentException( "Value $value must be either string or LikeMatch" ); + } + $this->values = [ $value ]; + + foreach ( $values as $value ) { + if ( !is_string( $value ) && !( $value instanceof LikeMatch ) ) { + throw new InvalidArgumentException( "Value $value must be either string or LikeMatch" ); + } + $this->values[] = $value; + } + } + + /** + * @internal to be used by rdbms library only + * @return-taint none + */ + public function toSql( DbQuoter $dbQuoter ): string { + $s = ''; + + // We use ` instead of \ as the default LIKE escape character, since addQuotes() + // may escape backslashes, creating problems of double escaping. The ` + // character has good cross-DBMS compatibility, avoiding special operators + // in MS SQL like ^ and % + $escapeChar = '`'; + + foreach ( $this->values as $value ) { + if ( $value instanceof LikeMatch ) { + $s .= $value->toString(); + } else { + $s .= $this->escapeLikeInternal( $value, $escapeChar ); + } + } + + return $dbQuoter->addQuotes( $s ) . ' ESCAPE ' . $dbQuoter->addQuotes( $escapeChar ) . ' '; + } + + private function escapeLikeInternal( $s, $escapeChar = '`' ) { + return str_replace( + [ $escapeChar, '%', '_' ], + [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ], + $s + ); + } +} diff --git a/includes/libs/rdbms/expression/OrExpressionGroup.php b/includes/libs/rdbms/expression/OrExpressionGroup.php index bc89436e6e0f..c64d07e5300b 100644 --- a/includes/libs/rdbms/expression/OrExpressionGroup.php +++ b/includes/libs/rdbms/expression/OrExpressionGroup.php @@ -15,9 +15,9 @@ class OrExpressionGroup extends ExpressionGroup { /** * @param string $field * @param-taint $field exec_sql - * @param string $op One of '>', '<', '!=', '=', '>=', '<=' + * @param string $op One of '>', '<', '!=', '=', '>=', '<=', IExpression::LIKE * @param-taint $op exec_sql - * @param string|int|float|bool|Blob|null|non-empty-list<string|int|float|bool|Blob> $value + * @param string|int|float|bool|Blob|null|LikeValue|non-empty-list<string|int|float|bool|Blob> $value * @param-taint $value escapes_sql */ public function or( string $field, string $op, $value ): OrExpressionGroup { diff --git a/includes/libs/rdbms/platform/SQLPlatform.php b/includes/libs/rdbms/platform/SQLPlatform.php index cfec055cef81..d4d8be845b59 100644 --- a/includes/libs/rdbms/platform/SQLPlatform.php +++ b/includes/libs/rdbms/platform/SQLPlatform.php @@ -30,6 +30,7 @@ use Wikimedia\Rdbms\DatabaseDomain; use Wikimedia\Rdbms\DBLanguageError; use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\LikeMatch; +use Wikimedia\Rdbms\LikeValue; use Wikimedia\Rdbms\Query; use Wikimedia\Rdbms\QueryBuilderFromRawSql; use Wikimedia\Rdbms\Subquery; @@ -437,25 +438,10 @@ class SQLPlatform implements ISQLPlatform { } else { $params = func_get_args(); } + // @phan-suppress-next-line PhanParamTooFewUnpack + $likeValue = new LikeValue( ...$params ); - $s = ''; - - // We use ` instead of \ as the default LIKE escape character, since addQuotes() - // may escape backslashes, creating problems of double escaping. The ` - // character has good cross-DBMS compatibility, avoiding special operators - // in MS SQL like ^ and % - $escapeChar = '`'; - - foreach ( $params as $value ) { - if ( $value instanceof LikeMatch ) { - $s .= $value->toString(); - } else { - $s .= $this->escapeLikeInternal( $value, $escapeChar ); - } - } - - return ' LIKE ' . - $this->quoter->addQuotes( $s ) . ' ESCAPE ' . $this->quoter->addQuotes( $escapeChar ) . ' '; + return ' LIKE ' . $likeValue->toSql( $this->quoter ); } public function anyChar() { diff --git a/includes/page/PageArchive.php b/includes/page/PageArchive.php index a57c37860792..40cbdf171cd5 100644 --- a/includes/page/PageArchive.php +++ b/includes/page/PageArchive.php @@ -25,8 +25,10 @@ use MediaWiki\Revision\RevisionRecord; use MediaWiki\Status\Status; use MediaWiki\Title\Title; use MediaWiki\User\UserIdentity; +use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\IReadableDatabase; use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\LikeValue; use Wikimedia\Rdbms\SelectQueryBuilder; /** @@ -88,15 +90,13 @@ class PageArchive { } $dbr = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getReplicaDatabase(); - $condTitles = array_unique( array_map( static function ( Title $t ) { + $condTitles = array_values( array_unique( array_map( static function ( Title $t ) { return $t->getDBkey(); - }, $results ) ); + }, $results ) ) ); $conds = [ 'ar_namespace' => $ns, - $dbr->makeList( [ - 'ar_title' => $condTitles, - 'ar_title' . $dbr->buildLike( $termDb, $dbr->anyString() ), - ], LIST_OR ), + $dbr->expr( 'ar_title', '=', $condTitles ) + ->or( 'ar_title', IExpression::LIKE, new LikeValue( $termDb, $dbr->anyString() ) ), ]; return self::listPages( $dbr, $conds ); @@ -125,7 +125,7 @@ class PageArchive { $conds = [ 'ar_namespace' => $ns, - 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ), + $dbr->expr( 'ar_title', IExpression::LIKE, new LikeValue( $prefix, $dbr->anyString() ) ), ]; return self::listPages( $dbr, $conds ); diff --git a/tests/phpunit/includes/ExternalLinks/LinkFilterTest.php b/tests/phpunit/includes/ExternalLinks/LinkFilterTest.php index 992562c88e19..da35d76afe09 100644 --- a/tests/phpunit/includes/ExternalLinks/LinkFilterTest.php +++ b/tests/phpunit/includes/ExternalLinks/LinkFilterTest.php @@ -2,6 +2,8 @@ use MediaWiki\ExternalLinks\LinkFilter; use MediaWiki\MainConfigNames; +use MediaWiki\Tests\Unit\Libs\Rdbms\AddQuoterMock; +use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\LikeMatch; /** @@ -497,7 +499,19 @@ class LinkFilterTest extends MediaWikiLangTestCase { ], ] ); $conds = LinkFilter::getQueryConditions( $query, $options ); - $this->assertEquals( $expected, $conds ); + if ( !$conds ) { + $this->assertEquals( $expected, $conds ); + return; + } + $sqlConds = []; + foreach ( $conds as $cond ) { + if ( $cond instanceof IExpression ) { + $sqlConds[] = $cond->toSql( new AddQuoterMock() ); + } else { + $sqlConds[] = $cond; + } + } + $this->assertEquals( $expected, $sqlConds ); } public static function provideGetQueryConditions() { @@ -506,8 +520,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { 'example.com', [], [ - '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' )) OR ' . - '((el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', + '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' ) OR ' . + '(el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', 'el_to_path LIKE \'/%\' ESCAPE \'`\' ', ], ], @@ -515,8 +529,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { 'example.com/foobar', [], [ - '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' )) OR ' . - '((el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', + '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' ) OR ' . + '(el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', 'el_to_path LIKE \'/foobar%\' ESCAPE \'`\' ', ], ], @@ -524,8 +538,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { '*.example.com', [], [ - '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' )) OR ' . - '((el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', + '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' ) OR ' . + '(el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', 'el_to_path LIKE \'/%\' ESCAPE \'`\' ', ], ], @@ -533,8 +547,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { '*.example.com/foobar', [], [ - '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' )) OR ' . - '((el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', + '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' ) OR ' . + '(el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', 'el_to_path LIKE \'/foobar%\' ESCAPE \'`\' ', ], ], @@ -542,8 +556,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { '*.example.com/foobar', [ 'oneWildcard' => true ], [ - '((el_to_domain_index = \'http://com.example.\')) OR ' . - '((el_to_domain_index = \'https://com.example.\'))', + '((el_to_domain_index = \'http://com.example.\') OR ' . + '(el_to_domain_index = \'https://com.example.\'))', 'el_to_path LIKE \'/foobar%\' ESCAPE \'`\' ', ], ], @@ -551,8 +565,8 @@ class LinkFilterTest extends MediaWikiLangTestCase { 'example.com/blah/blah/blah/blah/blah/blah/blah/blah/blah/blah?foo=', [], [ - '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' )) OR ' . - '((el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', + '((el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' ) OR ' . + '(el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\' ))', 'el_to_path LIKE ' . '\'/blah/blah/blah/blah/blah/blah/blah/blah/blah/blah?foo=%\' ' . 'ESCAPE \'`\' ', @@ -567,13 +581,13 @@ class LinkFilterTest extends MediaWikiLangTestCase { 'gaps.example', [], [ - '((el_to_domain_index LIKE \'http://example.gaps.%\' ESCAPE \'`\' ) AND ' . - '((el_id < 10) OR (el_id > 20)) AND ' . - '((el_id < 100) OR (el_id > 200)) AND ' . - '((el_id < 1000) OR (el_id > 2000))) OR ' . - '((el_to_domain_index LIKE \'https://example.gaps.%\' ESCAPE \'`\' ) AND ' . - '((el_id < 30) OR (el_id > 40)) AND ' . - '((el_id < 5000) OR (el_id > 60000)))', + '((el_to_domain_index LIKE \'http://example.gaps.%\' ESCAPE \'`\' AND ' . + '(el_id < 10 OR el_id > 20) AND ' . + '(el_id < 100 OR el_id > 200) AND ' . + '(el_id < 1000 OR el_id > 2000)) OR ' . + '(el_to_domain_index LIKE \'https://example.gaps.%\' ESCAPE \'`\' AND ' . + '(el_id < 30 OR el_id > 40) AND ' . + '(el_id < 5000 OR el_id > 60000)))', 'el_to_path LIKE \'/%\' ESCAPE \'`\' ', ], ], |