Commit 5c748ddb by Carsten Brandt

added case insensitve LIKE to PostgresQueryBuilder

fixes #3252 also improved unit tests for querybuilder buildLikeCondition
parent f182504a
......@@ -195,12 +195,16 @@ Operator can be one of the following:
it will be converted into a string using the rules described here. For example,
`['and', 'type=1', ['or', 'id=1', 'id=2']]` will generate `type=1 AND (id=1 OR id=2)`.
The method will NOT do any quoting or escaping.
- `or`: similar to the `and` operator except that the operands are concatenated using `OR`.
- `between`: operand 1 should be the column name, and operand 2 and 3 should be the
starting and ending values of the range that the column is in.
For example, `['between', 'id', 1, 10]` will generate `id BETWEEN 1 AND 10`.
- `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN`
in the generated condition.
- `in`: operand 1 should be a column or DB expression. Operand 2 can be either an array or a `Query` object.
It will generate an `IN` condition. If Operand 2 is an array, it will represent the range of the values
that the column or DB expression should be; If Operand 2 is a `Query` object, a sub-query will be generated
......@@ -209,7 +213,9 @@ Operator can be one of the following:
The method will properly quote the column name and escape values in the range.
The `in` operator also supports composite columns. In this case, operand 1 should be an array of the columns,
while operand 2 should be an array of arrays or a `Query` object representing the range of the columns.
- `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition.
- `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing
the values that the column or DB expression should be like.
For example, `['like', 'name', 'tester']` will generate `name LIKE '%tester%'`.
......@@ -222,14 +228,22 @@ Operator can be one of the following:
You may use `false` or an empty array to indicate the values are already escaped and no escape
should be applied. Note that when using an escape mapping (or the third operand is not provided),
the values will be automatically enclosed within a pair of percentage characters.
> Note: When using PostgreSQL you may also use [`ilike`](http://www.postgresql.org/docs/8.3/static/functions-matching.html#FUNCTIONS-LIKE)
> instead of `like` for case-insensitive matching.
- `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE`
predicates when operand 2 is an array.
- `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE`
in the generated condition.
- `or not like`: similar to the `not like` operator except that `OR` is used to concatenate
the `NOT LIKE` predicates.
- `exists`: requires one operand which must be an instance of [[yii\db\Query]] representing the sub-query.
It will build a `EXISTS (sub-query)` expression.
- `not exists`: similar to the `exists` operator and builds a `NOT EXISTS (sub-query)` expression.
If you are building parts of condition dynamically it's very convenient to use `andWhere()` and `orWhere()`:
......
......@@ -33,6 +33,7 @@ Yii Framework 2 Change Log
- Enh #3154: Added validation error display for `GridView` filters (ivan-kolmychek)
- Enh #3222: Added `useTablePrefix` option to the model generator for Gii (horizons2)
- Enh #3230: Added `yii\filters\AccessControl::user` to support access control with different actors (qiangxue)
- Enh #3252: Added support for case insensitive matching using ILIKE to PostgreSQL QueryBuilder (cebe)
- Enh: Added support for using sub-queries when building a DB query with `IN` condition (qiangxue)
- Enh: Supported adding a new response formatter without the need to reconfigure existing formatters (qiangxue)
- Enh: Added `yii\web\UrlManager::addRules()` to simplify adding new URL rules (qiangxue)
......
......@@ -41,6 +41,26 @@ class QueryBuilder extends \yii\base\Object
* Child classes should override this property to declare supported type mappings.
*/
public $typeMap = [];
/**
* @var array map of query condition to builder methods.
* These methods are used by [[buildCondition]] to build SQL conditions from array syntax.
*/
protected $conditionBuilders = [
'NOT' => 'buildNotCondition',
'AND' => 'buildAndCondition',
'OR' => 'buildAndCondition',
'BETWEEN' => 'buildBetweenCondition',
'NOT BETWEEN' => 'buildBetweenCondition',
'IN' => 'buildInCondition',
'NOT IN' => 'buildInCondition',
'LIKE' => 'buildLikeCondition',
'NOT LIKE' => 'buildLikeCondition',
'OR LIKE' => 'buildLikeCondition',
'OR NOT LIKE' => 'buildLikeCondition',
'EXISTS' => 'buildExistsCondition',
'NOT EXISTS' => 'buildExistsCondition',
];
/**
* Constructor.
......@@ -844,39 +864,22 @@ class QueryBuilder extends \yii\base\Object
*/
public function buildCondition($condition, &$params)
{
static $builders = [
'NOT' => 'buildNotCondition',
'AND' => 'buildAndCondition',
'OR' => 'buildAndCondition',
'BETWEEN' => 'buildBetweenCondition',
'NOT BETWEEN' => 'buildBetweenCondition',
'IN' => 'buildInCondition',
'NOT IN' => 'buildInCondition',
'LIKE' => 'buildLikeCondition',
'NOT LIKE' => 'buildLikeCondition',
'OR LIKE' => 'buildLikeCondition',
'OR NOT LIKE' => 'buildLikeCondition',
'EXISTS' => 'buildExistsCondition',
'NOT EXISTS' => 'buildExistsCondition',
];
if (!is_array($condition)) {
return (string) $condition;
} elseif (empty($condition)) {
return '';
}
if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ...
$operator = strtoupper($condition[0]);
if (isset($builders[$operator])) {
$method = $builders[$operator];
if (isset($this->conditionBuilders[$operator])) {
$method = $this->conditionBuilders[$operator];
array_shift($condition);
return $this->$method($operator, $condition, $params);
} else {
throw new InvalidParamException('Found unknown operator in query: ' . $operator);
}
} else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ...
return $this->buildHashCondition($condition, $params);
}
}
......@@ -912,7 +915,6 @@ class QueryBuilder extends \yii\base\Object
}
}
}
return count($parts) === 1 ? $parts[0] : '(' . implode(') AND (', $parts) . ')';
}
......@@ -1071,7 +1073,6 @@ class QueryBuilder extends \yii\base\Object
return "$column $operator (" . implode(', ', $values) . ')';
} else {
$operator = $operator === 'IN' ? '=' : '<>';
return $column . $operator . reset($values);
}
}
......@@ -1130,19 +1131,19 @@ class QueryBuilder extends \yii\base\Object
$escape = isset($operands[2]) ? $operands[2] : ['%'=>'\%', '_'=>'\_', '\\'=>'\\\\'];
unset($operands[2]);
if (!preg_match('/^(AND |OR |)(((NOT |))I?LIKE)/', $operator, $matches)) {
throw new InvalidParamException("Invalid operator '$operator'.");
}
$andor = ' ' . (!empty($matches[1]) ? $matches[1] : 'AND ');
$not = !empty($matches[3]);
$operator = $matches[2];
list($column, $values) = $operands;
$values = (array) $values;
if (empty($values)) {
return $operator === 'LIKE' || $operator === 'OR LIKE' ? '0=1' : '';
}
if ($operator === 'LIKE' || $operator === 'NOT LIKE') {
$andor = ' AND ';
} else {
$andor = ' OR ';
$operator = $operator === 'OR LIKE' ? 'LIKE' : 'NOT LIKE';
return $not ? '' : '0=1';
}
if (strpos($column, '(') === false) {
......@@ -1171,7 +1172,6 @@ class QueryBuilder extends \yii\base\Object
{
if ($operands[0] instanceof Query) {
list($sql, $params) = $this->build($operands[0], $params);
return "$operator ($sql)";
} else {
throw new InvalidParamException('Subquery for EXISTS operator must be a Query object.');
......
......@@ -17,7 +17,6 @@ use yii\base\InvalidParamException;
*/
class QueryBuilder extends \yii\db\QueryBuilder
{
/**
* @var array mapping from abstract column types (keys) to physical column types (values).
*/
......@@ -39,6 +38,30 @@ class QueryBuilder extends \yii\db\QueryBuilder
Schema::TYPE_BOOLEAN => 'boolean',
Schema::TYPE_MONEY => 'numeric(19,4)',
];
/**
* @var array map of query condition to builder methods.
* These methods are used by [[buildCondition]] to build SQL conditions from array syntax.
*/
protected $conditionBuilders = [
'NOT' => 'buildNotCondition',
'AND' => 'buildAndCondition',
'OR' => 'buildAndCondition',
'BETWEEN' => 'buildBetweenCondition',
'NOT BETWEEN' => 'buildBetweenCondition',
'IN' => 'buildInCondition',
'NOT IN' => 'buildInCondition',
'LIKE' => 'buildLikeCondition',
'ILIKE' => 'buildLikeCondition',
'NOT LIKE' => 'buildLikeCondition',
'NOT ILIKE' => 'buildLikeCondition',
'OR LIKE' => 'buildLikeCondition',
'OR ILIKE' => 'buildLikeCondition',
'OR NOT LIKE' => 'buildLikeCondition',
'OR NOT ILIKE' => 'buildLikeCondition',
'EXISTS' => 'buildExistsCondition',
'NOT EXISTS' => 'buildExistsCondition',
];
/**
* Builds a SQL statement for dropping an index.
......
......@@ -2,6 +2,7 @@
namespace yiiunit\framework\db;
use yii\db\Query;
use yii\db\QueryBuilder;
use yii\db\Schema;
use yii\db\mysql\QueryBuilder as MysqlQueryBuilder;
......@@ -24,15 +25,15 @@ class QueryBuilderTest extends DatabaseTestCase
{
switch ($this->driverName) {
case 'mysql':
return new MysqlQueryBuilder($this->getConnection());
return new MysqlQueryBuilder($this->getConnection(true, false));
case 'sqlite':
return new SqliteQueryBuilder($this->getConnection());
return new SqliteQueryBuilder($this->getConnection(true, false));
case 'mssql':
return new MssqlQueryBuilder($this->getConnection());
return new MssqlQueryBuilder($this->getConnection(true, false));
case 'pgsql':
return new PgsqlQueryBuilder($this->getConnection());
return new PgsqlQueryBuilder($this->getConnection(true, false));
case 'cubrid':
return new CubridQueryBuilder($this->getConnection());
return new CubridQueryBuilder($this->getConnection(true, false));
}
throw new \Exception('Test is not implemented for ' . $this->driverName);
}
......@@ -113,6 +114,58 @@ class QueryBuilderTest extends DatabaseTestCase
}
}
public function conditionProvider()
{
$conditions = [
// empty values
[ ['like', 'name', []], '0=1', [] ],
[ ['not like', 'name', []], '', [] ],
[ ['or like', 'name', []], '0=1', [] ],
[ ['or not like', 'name', []], '', [] ],
// simple like
[ ['like', 'name', 'heyho'], '"name" LIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['not like', 'name', 'heyho'], '"name" NOT LIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['or like', 'name', 'heyho'], '"name" LIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['or not like', 'name', 'heyho'], '"name" NOT LIKE :qp0', [':qp0' => '%heyho%'] ],
// like for many values
[ ['like', 'name', ['heyho', 'abc']], '"name" LIKE :qp0 AND "name" LIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['not like', 'name', ['heyho', 'abc']], '"name" NOT LIKE :qp0 AND "name" NOT LIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['or like', 'name', ['heyho', 'abc']], '"name" LIKE :qp0 OR "name" LIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['or not like', 'name', ['heyho', 'abc']], '"name" NOT LIKE :qp0 OR "name" NOT LIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
// TODO add more conditions
// IN
// NOT
// ...
];
// adjust dbms specific escaping
foreach($conditions as $i => $condition) {
switch ($this->driverName) {
case 'mssql':
case 'mysql':
case 'sqlite':
$conditions[$i][1] = str_replace('"', '`', $condition[1]);
break;
}
}
return $conditions;
}
/**
* @dataProvider conditionProvider
*/
public function testBuildCondition($condition, $expected, $expectedParams)
{
$query = (new Query())->where($condition);
list($sql, $params) = $this->getQueryBuilder()->build($query);
$this->assertEquals($expectedParams, $params);
$this->assertEquals('SELECT *' . (empty($expected) ? '' : ' WHERE ' . $expected), $sql);
}
public function testAddDropPrimaryKey()
{
$tableName = 'constraints';
......
......@@ -74,4 +74,30 @@ class PostgreSQLQueryBuilderTest extends QueryBuilderTest
[Schema::TYPE_MONEY . ' NOT NULL', 'numeric(19,4) NOT NULL'],
];
}
public function conditionProvider()
{
return array_merge(parent::conditionProvider(), [
// adding conditions for ILIKE i.e. case insensitive LIKE
// http://www.postgresql.org/docs/8.3/static/functions-matching.html#FUNCTIONS-LIKE
// empty values
[ ['ilike', 'name', []], '0=1', [] ],
[ ['not ilike', 'name', []], '', [] ],
[ ['or ilike', 'name', []], '0=1', [] ],
[ ['or not ilike', 'name', []], '', [] ],
// simple ilike
[ ['ilike', 'name', 'heyho'], '"name" ILIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['not ilike', 'name', 'heyho'], '"name" NOT ILIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['or ilike', 'name', 'heyho'], '"name" ILIKE :qp0', [':qp0' => '%heyho%'] ],
[ ['or not ilike', 'name', 'heyho'], '"name" NOT ILIKE :qp0', [':qp0' => '%heyho%'] ],
// ilike for many values
[ ['ilike', 'name', ['heyho', 'abc']], '"name" ILIKE :qp0 AND "name" ILIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['not ilike', 'name', ['heyho', 'abc']], '"name" NOT ILIKE :qp0 AND "name" NOT ILIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['or ilike', 'name', ['heyho', 'abc']], '"name" ILIKE :qp0 OR "name" ILIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
[ ['or not ilike', 'name', ['heyho', 'abc']], '"name" NOT ILIKE :qp0 OR "name" NOT ILIKE :qp1', [':qp0' => '%heyho%', ':qp1' => '%abc%'] ],
]);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment