Skip to content

Commit 6bd8b78

Browse files
feat(like): add or and negated contains variants
1 parent c9c8741 commit 6bd8b78

4 files changed

Lines changed: 230 additions & 14 deletions

File tree

src/LikeOperator.php

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Database\Connection;
88
use Illuminate\Database\Eloquent\Builder;
9+
use InvalidArgumentException;
910

1011
final class LikeOperator
1112
{
@@ -31,40 +32,82 @@ public static function containsPattern(string $term): string
3132
}
3233

3334
/**
35+
* Adds `column LIKE '%term%'` (ILIKE on PostgreSQL) to the query.
36+
*
37+
* `$boolean` and `$not` mirror Laravel's `whereLike()`: `'or'` joins the
38+
* clause with OR instead of AND, and `$not` negates it into NOT LIKE or
39+
* NOT ILIKE. A NULL column never matches, negated or not.
40+
*
3441
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
42+
* @param 'and'|'or' $boolean
3543
*/
36-
public static function applyContains(Builder $query, string $column, string $term): void
44+
public static function applyContains(Builder $query, string $column, string $term, string $boolean = 'and', bool $not = false): void
3745
{
38-
$pattern = self::containsPattern($term);
3946
$wrappedColumn = $query->getGrammar()->wrap($column);
40-
/** @var Connection $connection */
41-
$connection = $query->getConnection();
42-
$operator = self::for($query);
43-
$escape = self::escapeClause($connection->getDriverName());
4447

45-
$query->whereRaw("{$wrappedColumn} {$operator} ? {$escape}", [$pattern]);
48+
self::applyPattern($query, $wrappedColumn, $term, $boolean, $not);
49+
}
50+
51+
/**
52+
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
53+
*/
54+
public static function orApplyContains(Builder $query, string $column, string $term): void
55+
{
56+
self::applyContains($query, $column, $term, 'or');
4657
}
4758

4859
/**
4960
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
5061
*/
51-
public static function applyContainsOnDate(Builder $query, string $column, string $term): void
62+
public static function applyNotContains(Builder $query, string $column, string $term): void
63+
{
64+
self::applyContains($query, $column, $term, 'and', true);
65+
}
66+
67+
/**
68+
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
69+
*/
70+
public static function orApplyNotContains(Builder $query, string $column, string $term): void
71+
{
72+
self::applyContains($query, $column, $term, 'or', true);
73+
}
74+
75+
/**
76+
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
77+
* @param 'and'|'or' $boolean
78+
*/
79+
public static function applyContainsOnDate(Builder $query, string $column, string $term, string $boolean = 'and', bool $not = false): void
5280
{
53-
$pattern = self::containsPattern($term);
5481
$wrappedColumn = $query->getGrammar()->wrap($column);
5582
/** @var Connection $connection */
5683
$connection = $query->getConnection();
57-
$driver = $connection->getDriverName();
58-
$operator = self::for($query);
59-
$escape = self::escapeClause($driver);
6084

61-
$expression = match ($driver) {
85+
$expression = match ($connection->getDriverName()) {
6286
'pgsql' => "{$wrappedColumn}::text",
6387
'mysql', 'mariadb' => "CAST({$wrappedColumn} AS CHAR)",
6488
default => "CAST({$wrappedColumn} AS TEXT)",
6589
};
6690

67-
$query->whereRaw("{$expression} {$operator} ? {$escape}", [$pattern]);
91+
self::applyPattern($query, $expression, $term, $boolean, $not);
92+
}
93+
94+
/**
95+
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
96+
*/
97+
private static function applyPattern(Builder $query, string $expression, string $term, string $boolean, bool $not): void
98+
{
99+
$boolean = strtolower($boolean);
100+
101+
if ($boolean !== 'and' && $boolean !== 'or') {
102+
throw new InvalidArgumentException("The boolean must be 'and' or 'or', got '{$boolean}'.");
103+
}
104+
105+
/** @var Connection $connection */
106+
$connection = $query->getConnection();
107+
$operator = ($not ? 'NOT ' : '').self::for($query);
108+
$escape = self::escapeClause($connection->getDriverName());
109+
110+
$query->whereRaw("{$expression} {$operator} ? {$escape}", [self::containsPattern($term)], $boolean);
68111
}
69112

70113
/**

tests/LikeOperatorTest.php

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use Illuminate\Database\Eloquent\Builder;
56
use PlinCode\SqlDialect\LikeOperator;
67
use Workbench\App\Models\Document;
78

@@ -95,3 +96,173 @@
9596

9697
expect($query->pluck('title')->all())->toBe(['a']);
9798
})->group('integration');
99+
100+
it('adds a contains clause with or instead of and', function (): void {
101+
Document::create(['title' => 'backend developer']);
102+
Document::create(['title' => 'data_engineer']);
103+
Document::create(['title' => 'data engineer']);
104+
Document::create(['title' => 'designer']);
105+
106+
$query = Document::query()->where(function (Builder $query): void {
107+
LikeOperator::orApplyContains($query, 'title', 'backend');
108+
LikeOperator::orApplyContains($query, 'title', 'data_engineer');
109+
});
110+
111+
expect($query->orderBy('id')->pluck('title')->all())->toBe(['backend developer', 'data_engineer']);
112+
})->group('integration');
113+
114+
it('keeps an or contains clause inside its group', function (): void {
115+
Document::create(['title' => 'backend developer', 'summary' => 'keep']);
116+
Document::create(['title' => 'backend developer', 'summary' => 'drop']);
117+
Document::create(['title' => 'frontend developer', 'summary' => 'keep']);
118+
119+
$query = Document::query()
120+
->where('summary', 'keep')
121+
->where(function (Builder $query): void {
122+
LikeOperator::orApplyContains($query, 'title', 'backend');
123+
LikeOperator::orApplyContains($query, 'title', 'nothing matches');
124+
});
125+
126+
expect($query->pluck('title')->all())->toBe(['backend developer']);
127+
})->group('integration');
128+
129+
it('treats a percent sign as a literal in an or contains clause', function (): void {
130+
Document::create(['title' => '100% remote']);
131+
Document::create(['title' => '100 remote']);
132+
133+
$query = Document::query()->where(function (Builder $query): void {
134+
LikeOperator::orApplyContains($query, 'title', '100% remote');
135+
});
136+
137+
expect($query->pluck('title')->all())->toBe(['100% remote']);
138+
})->group('integration');
139+
140+
it('excludes rows containing the term', function (): void {
141+
Document::create(['title' => 'senior engineer']);
142+
Document::create(['title' => 'junior engineer']);
143+
144+
$query = Document::query();
145+
LikeOperator::applyNotContains($query, 'title', 'senior');
146+
147+
expect($query->pluck('title')->all())->toBe(['junior engineer']);
148+
})->group('integration');
149+
150+
it('treats a percent sign as a literal in a not contains clause', function (): void {
151+
Document::create(['title' => '100% remote']);
152+
Document::create(['title' => '100 remote']);
153+
154+
$query = Document::query();
155+
LikeOperator::applyNotContains($query, 'title', '100% remote');
156+
157+
expect($query->pluck('title')->all())->toBe(['100 remote']);
158+
})->group('integration');
159+
160+
it('treats an underscore as a literal in a not contains clause', function (): void {
161+
Document::create(['title' => 'data_engineer']);
162+
Document::create(['title' => 'data engineer']);
163+
164+
$query = Document::query();
165+
LikeOperator::applyNotContains($query, 'title', 'data_engineer');
166+
167+
expect($query->pluck('title')->all())->toBe(['data engineer']);
168+
})->group('integration');
169+
170+
it('treats a backslash as a literal in a not contains clause', function (): void {
171+
Document::create(['title' => 'path\\to']);
172+
Document::create(['title' => 'pathto']);
173+
174+
$query = Document::query();
175+
LikeOperator::applyNotContains($query, 'title', 'path\\to');
176+
177+
expect($query->pluck('title')->all())->toBe(['pathto']);
178+
})->group('integration');
179+
180+
it('ands consecutive not contains clauses', function (): void {
181+
Document::create(['title' => 'senior engineer']);
182+
Document::create(['title' => 'lead engineer']);
183+
Document::create(['title' => 'engineer']);
184+
185+
$query = Document::query();
186+
LikeOperator::applyNotContains($query, 'title', 'senior');
187+
LikeOperator::applyNotContains($query, 'title', 'lead');
188+
189+
expect($query->pluck('title')->all())->toBe(['engineer']);
190+
})->group('integration');
191+
192+
it('excludes a null column from a not contains clause', function (): void {
193+
Document::create(['title' => 'a', 'summary' => null]);
194+
Document::create(['title' => 'b', 'summary' => 'remote']);
195+
Document::create(['title' => 'c', 'summary' => 'office']);
196+
197+
$query = Document::query();
198+
LikeOperator::applyNotContains($query, 'summary', 'remote');
199+
200+
expect($query->pluck('title')->all())->toBe(['c']);
201+
})->group('integration');
202+
203+
it('keeps a null column when grouped with or where null', function (): void {
204+
Document::create(['title' => 'a', 'summary' => null]);
205+
Document::create(['title' => 'b', 'summary' => 'remote']);
206+
Document::create(['title' => 'c', 'summary' => 'office']);
207+
208+
$query = Document::query()->where(function (Builder $query): void {
209+
$query->whereNull('summary');
210+
LikeOperator::orApplyNotContains($query, 'summary', 'remote');
211+
});
212+
213+
expect($query->orderBy('id')->pluck('title')->all())->toBe(['a', 'c']);
214+
})->group('integration');
215+
216+
it('adds a not contains clause with or instead of and', function (): void {
217+
Document::create(['title' => 'senior engineer', 'summary' => 'keep']);
218+
Document::create(['title' => 'senior engineer', 'summary' => 'drop']);
219+
Document::create(['title' => 'junior engineer', 'summary' => 'drop']);
220+
221+
$query = Document::query()->where(function (Builder $query): void {
222+
$query->where('summary', 'keep');
223+
LikeOperator::orApplyNotContains($query, 'title', 'senior');
224+
});
225+
226+
expect($query->orderBy('id')->pluck('title')->all())->toBe(['senior engineer', 'junior engineer'])
227+
->and($query->pluck('summary')->sort()->values()->all())->toBe(['drop', 'keep']);
228+
})->group('integration');
229+
230+
it('accepts the boolean and the negation as arguments', function (): void {
231+
Document::create(['title' => 'senior engineer']);
232+
Document::create(['title' => 'junior engineer']);
233+
Document::create(['title' => 'designer']);
234+
235+
$query = Document::query()->where(function (Builder $query): void {
236+
LikeOperator::applyContains($query, 'title', 'engineer', 'or', true);
237+
LikeOperator::applyContains($query, 'title', 'junior', 'or');
238+
});
239+
240+
expect($query->orderBy('id')->pluck('title')->all())->toBe(['junior engineer', 'designer']);
241+
})->group('integration');
242+
243+
it('excludes a substring of a date column', function (): void {
244+
Document::create(['title' => 'a', 'issued_on' => '2019-07-14']);
245+
Document::create(['title' => 'b', 'issued_on' => '2020-07-14']);
246+
247+
$query = Document::query();
248+
LikeOperator::applyContainsOnDate($query, 'issued_on', '2019', 'and', true);
249+
250+
expect($query->pluck('title')->all())->toBe(['b']);
251+
})->group('integration');
252+
253+
it('matches a substring of a date column with or', function (): void {
254+
Document::create(['title' => 'a', 'issued_on' => '2019-07-14']);
255+
Document::create(['title' => 'b', 'issued_on' => '2020-07-14']);
256+
Document::create(['title' => 'c', 'issued_on' => '2021-07-14']);
257+
258+
$query = Document::query()->where(function (Builder $query): void {
259+
LikeOperator::applyContainsOnDate($query, 'issued_on', '2019', 'or');
260+
LikeOperator::applyContainsOnDate($query, 'issued_on', '2021', 'or');
261+
});
262+
263+
expect($query->orderBy('id')->pluck('title')->all())->toBe(['a', 'c']);
264+
})->group('integration');
265+
266+
it('rejects a boolean other than and or or', function (): void {
267+
LikeOperator::applyContains(Document::query(), 'title', 'x', 'xor');
268+
})->throws(InvalidArgumentException::class, "The boolean must be 'and' or 'or', got 'xor'.");

workbench/app/Models/Document.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/**
1010
* @property string $title
11+
* @property string|null $summary
1112
* @property string|null $issued_on
1213
* @property string|null $recorded_at
1314
* @property string|null $from

workbench/database/migrations/0001_01_01_000000_create_documents_table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public function up(): void
1313
Schema::create('documents', function (Blueprint $table): void {
1414
$table->increments('id');
1515
$table->string('title');
16+
$table->string('summary')->nullable();
1617
$table->date('issued_on')->nullable();
1718
$table->timestamp('recorded_at')->nullable();
1819
// `from` e' una parola riservata in MySQL e PostgreSQL: serve a

0 commit comments

Comments
 (0)