Skip to content

Commit df87261

Browse files
authored
Split Like condition to Like and NotLike (#1023)
1 parent 6987f72 commit df87261

File tree

9 files changed

+207
-203
lines changed

9 files changed

+207
-203
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
- Chg #1017: Split `Between` condition to `Between` and `NotBetween` (@vjik)
126126
- New #1020: Support column's collation (@Tigrov)
127127
- Chg #1021: Move conjunction type from operator string value to `Like` condition constructor parameter (@vjik)
128+
- Chg #1023: Split `Like` condition to `Like` and `NotLike` (@vjik)
128129

129130
## 1.3.0 March 21, 2024
130131

src/QueryBuilder/AbstractDQLQueryBuilder.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ abstract class AbstractDQLQueryBuilder implements DQLQueryBuilderInterface
5858
*
5959
* ```php
6060
* return [
61-
* 'LIKE' => \Yiisoft\Db\Condition\LikeCondition::class,
61+
* 'LIKE' => \Yiisoft\Db\QueryBuilder\Condition\Like::class,
6262
* ];
6363
* ```
6464
*
@@ -523,7 +523,7 @@ protected function defaultConditionClasses(): array
523523
'IN' => Condition\In::class,
524524
'NOT IN' => Condition\NotIn::class,
525525
'LIKE' => Condition\Like::class,
526-
'NOT LIKE' => Condition\Like::class,
526+
'NOT LIKE' => Condition\NotLike::class,
527527
'EXISTS' => Condition\Exists::class,
528528
'NOT EXISTS' => Condition\Exists::class,
529529
'ARRAY OVERLAPS' => Condition\ArrayOverlaps::class,
@@ -554,6 +554,7 @@ protected function defaultExpressionBuilders(): array
554554
Condition\In::class => Condition\Builder\InBuilder::class,
555555
Condition\NotIn::class => Condition\Builder\InBuilder::class,
556556
Condition\Like::class => Condition\Builder\LikeBuilder::class,
557+
Condition\NotLike::class => Condition\Builder\LikeBuilder::class,
557558
Condition\Equals::class => Condition\Builder\EqualsBuilder::class,
558559
Condition\Exists::class => Condition\Builder\ExistsBuilder::class,
559560
Simple::class => Condition\Builder\SimpleBuilder::class,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Db\QueryBuilder\Condition;
6+
7+
use InvalidArgumentException;
8+
use Yiisoft\Db\Expression\ExpressionInterface;
9+
10+
use function is_int;
11+
use function is_string;
12+
use function sprintf;
13+
14+
/**
15+
* @internal
16+
*
17+
* Condition that represents `LIKE` operator.
18+
*/
19+
abstract class AbstractLike implements ConditionInterface
20+
{
21+
private const DEFAULT_ESCAPE = true;
22+
private const DEFAULT_MODE = LikeMode::Contains;
23+
private const DEFAULT_CONJUNCTION = LikeConjunction::And;
24+
25+
/**
26+
* @param ExpressionInterface|string $column The column name.
27+
* @param ExpressionInterface|int|iterable|string|null $value The value to the right of operator.
28+
* @param bool|null $caseSensitive Whether the comparison is case-sensitive. `null` means using the default
29+
* behavior.
30+
* @param bool $escape Whether to escape the value. Defaults to `true`. If `false`, the value will be used as is
31+
* without escaping.
32+
* @param LikeMode $mode The mode for the LIKE operation (contains, starts with, ends with or custom pattern).
33+
* @param LikeConjunction $conjunction The conjunction to use for combining multiple LIKE conditions.
34+
*/
35+
final public function __construct(
36+
public readonly string|ExpressionInterface $column,
37+
public readonly iterable|int|string|ExpressionInterface|null $value,
38+
public readonly ?bool $caseSensitive = null,
39+
public readonly bool $escape = self::DEFAULT_ESCAPE,
40+
public readonly LikeMode $mode = self::DEFAULT_MODE,
41+
public readonly LikeConjunction $conjunction = self::DEFAULT_CONJUNCTION,
42+
) {
43+
}
44+
45+
/**
46+
* Creates a condition based on the given operator and operands.
47+
*
48+
* @throws InvalidArgumentException If the number of operands isn't 2.
49+
*/
50+
final public static function fromArrayDefinition(string $operator, array $operands): static
51+
{
52+
if (!isset($operands[0], $operands[1])) {
53+
throw new InvalidArgumentException("Operator '$operator' requires two operands.");
54+
}
55+
56+
if (isset($operands['mode'])) {
57+
$mode = $operands['mode'];
58+
if (!$mode instanceof LikeMode) {
59+
throw new InvalidArgumentException(
60+
sprintf(
61+
'Operator "%s" requires "mode" to be an instance of %s. Got %s.',
62+
$operator,
63+
LikeMode::class,
64+
get_debug_type($mode),
65+
),
66+
);
67+
}
68+
} else {
69+
$mode = self::DEFAULT_MODE;
70+
}
71+
72+
if (isset($operands['conjunction'])) {
73+
$conjunction = $operands['conjunction'];
74+
if (!$conjunction instanceof LikeConjunction) {
75+
throw new InvalidArgumentException(
76+
sprintf(
77+
'Operator "%s" requires "conjunction" to be an instance of %s. Got %s.',
78+
$operator,
79+
LikeConjunction::class,
80+
get_debug_type($conjunction),
81+
),
82+
);
83+
}
84+
} else {
85+
$conjunction = self::DEFAULT_CONJUNCTION;
86+
}
87+
88+
return new static(
89+
self::validateColumn($operator, $operands[0]),
90+
self::validateValue($operator, $operands[1]),
91+
isset($operands['caseSensitive']) ? (bool) $operands['caseSensitive'] : null,
92+
isset($operands['escape']) ? (bool) $operands['escape'] : self::DEFAULT_ESCAPE,
93+
$mode,
94+
$conjunction,
95+
);
96+
}
97+
98+
/**
99+
* Validates the given column to be `string` or `ExpressionInterface`.
100+
*
101+
* @throws InvalidArgumentException
102+
*/
103+
private static function validateColumn(string $operator, mixed $column): string|ExpressionInterface
104+
{
105+
if (is_string($column) || $column instanceof ExpressionInterface) {
106+
return $column;
107+
}
108+
109+
throw new InvalidArgumentException("Operator '$operator' requires column to be string or ExpressionInterface.");
110+
}
111+
112+
/**
113+
* Validates the given values to be `string`, `int`, `iterable` or `ExpressionInterface`.
114+
*
115+
* @throws InvalidArgumentException If the values aren't `string`, `int`, `iterable` or `ExpressionInterface`.
116+
*/
117+
private static function validateValue(
118+
string $operator,
119+
mixed $value
120+
): iterable|int|string|ExpressionInterface|null {
121+
if (
122+
is_string($value) ||
123+
is_iterable($value) ||
124+
is_int($value) ||
125+
$value instanceof ExpressionInterface ||
126+
$value === null
127+
) {
128+
return $value;
129+
}
130+
131+
throw new InvalidArgumentException(
132+
"Operator '$operator' requires value to be string, int, iterable or ExpressionInterface."
133+
);
134+
}
135+
}

src/QueryBuilder/Condition/Builder/LikeBuilder.php

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Yiisoft\Db\QueryBuilder\Condition\Builder;
66

7+
use Traversable;
78
use Yiisoft\Db\Command\Param;
89
use Yiisoft\Db\Constant\DataType;
910
use Yiisoft\Db\Exception\Exception;
@@ -15,27 +16,25 @@
1516
use Yiisoft\Db\QueryBuilder\Condition\Like;
1617
use Yiisoft\Db\QueryBuilder\Condition\LikeConjunction;
1718
use Yiisoft\Db\QueryBuilder\Condition\LikeMode;
19+
use Yiisoft\Db\QueryBuilder\Condition\NotLike;
1820
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
1921

2022
use function implode;
21-
use function is_array;
22-
use function preg_match;
23+
use function is_string;
2324
use function str_contains;
24-
use function strtoupper;
2525
use function strtr;
2626

2727
/**
28-
* Build an object of {@see Like} into SQL expressions.
28+
* Build an object of {@see Like} or {@see NotLike} into SQL expressions.
2929
*
30-
* @implements ExpressionBuilderInterface<Like>
30+
* @implements ExpressionBuilderInterface<Like|NotLike>
3131
*/
3232
class LikeBuilder implements ExpressionBuilderInterface
3333
{
34-
public function __construct(
35-
private readonly QueryBuilderInterface $queryBuilder,
36-
private readonly string|null $escapeSql = null
37-
) {
38-
}
34+
/**
35+
* @var string SQL fragment to append to the end of `LIKE` conditions.
36+
*/
37+
protected const ESCAPE_SQL = '';
3938

4039
/**
4140
* @var array Map of chars to their replacements in `LIKE` conditions. By default, it's configured to escape
@@ -47,10 +46,15 @@ public function __construct(
4746
'\\' => '\\\\',
4847
];
4948

49+
public function __construct(
50+
private readonly QueryBuilderInterface $queryBuilder,
51+
) {
52+
}
53+
5054
/**
51-
* Build SQL for {@see Like}.
55+
* Build SQL for {@see Like} or {@see NotLike}.
5256
*
53-
* @param Like $expression
57+
* @param Like|NotLike $expression
5458
*
5559
* @throws Exception
5660
* @throws InvalidArgumentException
@@ -61,24 +65,30 @@ public function build(ExpressionInterface $expression, array &$params = []): str
6165
{
6266
$values = $expression->value;
6367

64-
[$not, $operator] = $this->parseOperator($expression);
68+
[$not, $operator] = $this->getOperatorData($expression);
6569

66-
if (!is_array($values)) {
67-
$values = [$values];
70+
if ($values === null) {
71+
return $this->buildForEmptyValue($not);
6872
}
6973

70-
if (empty($values)) {
71-
return $not ? '' : '0=1';
74+
if (is_iterable($values)) {
75+
if ($values instanceof Traversable) {
76+
$values = iterator_to_array($values);
77+
}
78+
if (empty($values)) {
79+
return $this->buildForEmptyValue($not);
80+
}
81+
} else {
82+
$values = [$values];
7283
}
7384

7485
$column = $this->prepareColumn($expression, $params);
7586

7687
$parts = [];
77-
78-
/** @psalm-var list<string|ExpressionInterface> $values */
7988
foreach ($values as $value) {
89+
/** @var ExpressionInterface|int|string $value */
8090
$placeholderName = $this->preparePlaceholderName($value, $expression, $params);
81-
$parts[] = "$column $operator $placeholderName$this->escapeSql";
91+
$parts[] = "$column $operator $placeholderName" . static::ESCAPE_SQL;
8292
}
8393

8494
$conjunction = match ($expression->conjunction) {
@@ -97,7 +107,7 @@ public function build(ExpressionInterface $expression, array &$params = []): str
97107
* @throws InvalidConfigException
98108
* @throws NotSupportedException
99109
*/
100-
protected function prepareColumn(Like $condition, array &$params): string
110+
protected function prepareColumn(Like|NotLike $condition, array &$params): string
101111
{
102112
$column = $condition->column;
103113

@@ -122,45 +132,43 @@ protected function prepareColumn(Like $condition, array &$params): string
122132
* @return string
123133
*/
124134
protected function preparePlaceholderName(
125-
string|ExpressionInterface $value,
126-
Like $condition,
135+
string|int|ExpressionInterface $value,
136+
Like|NotLike $condition,
127137
array &$params,
128138
): string {
129139
if ($value instanceof ExpressionInterface) {
130140
return $this->queryBuilder->buildExpression($value, $params);
131141
}
132142

133-
if ($condition->escape) {
143+
if (is_string($value) && $condition->escape) {
134144
$value = strtr($value, $this->escapingReplacements);
135145
}
136146

137147
$value = match ($condition->mode) {
138148
LikeMode::Contains => '%' . $value . '%',
139149
LikeMode::StartsWith => $value . '%',
140150
LikeMode::EndsWith => '%' . $value,
141-
LikeMode::Custom => $value,
151+
LikeMode::Custom => (string) $value,
142152
};
143153

144154
return $this->queryBuilder->bindParam(new Param($value, DataType::STRING), $params);
145155
}
146156

147157
/**
148-
* Parses operator and returns its parts.
149-
*
150-
* @throws InvalidArgumentException
158+
* Get operator and `not` flag for the given condition.
151159
*
152160
* @psalm-return array{0: bool, 1: string}
153161
*/
154-
protected function parseOperator(Like $condition): array
162+
protected function getOperatorData(Like|NotLike $condition): array
155163
{
156-
$operator = strtoupper($condition->operator);
157-
if (!preg_match('/^((NOT |)I?LIKE)/', $operator, $matches)) {
158-
throw new InvalidArgumentException("Invalid operator in like condition: \"$operator\"");
159-
}
160-
161-
$not = !empty($matches[2]);
162-
$operator = $matches[1];
164+
return match ($condition::class) {
165+
Like::class => [false, 'LIKE'],
166+
NotLike::class => [true, 'NOT LIKE'],
167+
};
168+
}
163169

164-
return [$not, $operator];
170+
private function buildForEmptyValue(bool $not): string
171+
{
172+
return $not ? '' : '0=1';
165173
}
166174
}

0 commit comments

Comments
 (0)