From 6bd8b7847e6a0a993a26a41e95f2ed24525ca39b Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Thu, 17 Sep 2026 15:58:40 +0200 Subject: [PATCH 1/2] feat(like): add or and negated contains variants --- src/LikeOperator.php | 71 ++++++-- tests/LikeOperatorTest.php | 171 ++++++++++++++++++ workbench/app/Models/Document.php | 1 + ...01_01_01_000000_create_documents_table.php | 1 + 4 files changed, 230 insertions(+), 14 deletions(-) diff --git a/src/LikeOperator.php b/src/LikeOperator.php index 60fb6c2..d948aee 100644 --- a/src/LikeOperator.php +++ b/src/LikeOperator.php @@ -6,6 +6,7 @@ use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder; +use InvalidArgumentException; final class LikeOperator { @@ -31,40 +32,82 @@ public static function containsPattern(string $term): string } /** + * Adds `column LIKE '%term%'` (ILIKE on PostgreSQL) to the query. + * + * `$boolean` and `$not` mirror Laravel's `whereLike()`: `'or'` joins the + * clause with OR instead of AND, and `$not` negates it into NOT LIKE or + * NOT ILIKE. A NULL column never matches, negated or not. + * * @param Builder $query + * @param 'and'|'or' $boolean */ - public static function applyContains(Builder $query, string $column, string $term): void + public static function applyContains(Builder $query, string $column, string $term, string $boolean = 'and', bool $not = false): void { - $pattern = self::containsPattern($term); $wrappedColumn = $query->getGrammar()->wrap($column); - /** @var Connection $connection */ - $connection = $query->getConnection(); - $operator = self::for($query); - $escape = self::escapeClause($connection->getDriverName()); - $query->whereRaw("{$wrappedColumn} {$operator} ? {$escape}", [$pattern]); + self::applyPattern($query, $wrappedColumn, $term, $boolean, $not); + } + + /** + * @param Builder $query + */ + public static function orApplyContains(Builder $query, string $column, string $term): void + { + self::applyContains($query, $column, $term, 'or'); } /** * @param Builder $query */ - public static function applyContainsOnDate(Builder $query, string $column, string $term): void + public static function applyNotContains(Builder $query, string $column, string $term): void + { + self::applyContains($query, $column, $term, 'and', true); + } + + /** + * @param Builder $query + */ + public static function orApplyNotContains(Builder $query, string $column, string $term): void + { + self::applyContains($query, $column, $term, 'or', true); + } + + /** + * @param Builder $query + * @param 'and'|'or' $boolean + */ + public static function applyContainsOnDate(Builder $query, string $column, string $term, string $boolean = 'and', bool $not = false): void { - $pattern = self::containsPattern($term); $wrappedColumn = $query->getGrammar()->wrap($column); /** @var Connection $connection */ $connection = $query->getConnection(); - $driver = $connection->getDriverName(); - $operator = self::for($query); - $escape = self::escapeClause($driver); - $expression = match ($driver) { + $expression = match ($connection->getDriverName()) { 'pgsql' => "{$wrappedColumn}::text", 'mysql', 'mariadb' => "CAST({$wrappedColumn} AS CHAR)", default => "CAST({$wrappedColumn} AS TEXT)", }; - $query->whereRaw("{$expression} {$operator} ? {$escape}", [$pattern]); + self::applyPattern($query, $expression, $term, $boolean, $not); + } + + /** + * @param Builder $query + */ + private static function applyPattern(Builder $query, string $expression, string $term, string $boolean, bool $not): void + { + $boolean = strtolower($boolean); + + if ($boolean !== 'and' && $boolean !== 'or') { + throw new InvalidArgumentException("The boolean must be 'and' or 'or', got '{$boolean}'."); + } + + /** @var Connection $connection */ + $connection = $query->getConnection(); + $operator = ($not ? 'NOT ' : '').self::for($query); + $escape = self::escapeClause($connection->getDriverName()); + + $query->whereRaw("{$expression} {$operator} ? {$escape}", [self::containsPattern($term)], $boolean); } /** diff --git a/tests/LikeOperatorTest.php b/tests/LikeOperatorTest.php index 236fe6e..8fa8df6 100644 --- a/tests/LikeOperatorTest.php +++ b/tests/LikeOperatorTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\Eloquent\Builder; use PlinCode\SqlDialect\LikeOperator; use Workbench\App\Models\Document; @@ -95,3 +96,173 @@ expect($query->pluck('title')->all())->toBe(['a']); })->group('integration'); + +it('adds a contains clause with or instead of and', function (): void { + Document::create(['title' => 'backend developer']); + Document::create(['title' => 'data_engineer']); + Document::create(['title' => 'data engineer']); + Document::create(['title' => 'designer']); + + $query = Document::query()->where(function (Builder $query): void { + LikeOperator::orApplyContains($query, 'title', 'backend'); + LikeOperator::orApplyContains($query, 'title', 'data_engineer'); + }); + + expect($query->orderBy('id')->pluck('title')->all())->toBe(['backend developer', 'data_engineer']); +})->group('integration'); + +it('keeps an or contains clause inside its group', function (): void { + Document::create(['title' => 'backend developer', 'summary' => 'keep']); + Document::create(['title' => 'backend developer', 'summary' => 'drop']); + Document::create(['title' => 'frontend developer', 'summary' => 'keep']); + + $query = Document::query() + ->where('summary', 'keep') + ->where(function (Builder $query): void { + LikeOperator::orApplyContains($query, 'title', 'backend'); + LikeOperator::orApplyContains($query, 'title', 'nothing matches'); + }); + + expect($query->pluck('title')->all())->toBe(['backend developer']); +})->group('integration'); + +it('treats a percent sign as a literal in an or contains clause', function (): void { + Document::create(['title' => '100% remote']); + Document::create(['title' => '100 remote']); + + $query = Document::query()->where(function (Builder $query): void { + LikeOperator::orApplyContains($query, 'title', '100% remote'); + }); + + expect($query->pluck('title')->all())->toBe(['100% remote']); +})->group('integration'); + +it('excludes rows containing the term', function (): void { + Document::create(['title' => 'senior engineer']); + Document::create(['title' => 'junior engineer']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'title', 'senior'); + + expect($query->pluck('title')->all())->toBe(['junior engineer']); +})->group('integration'); + +it('treats a percent sign as a literal in a not contains clause', function (): void { + Document::create(['title' => '100% remote']); + Document::create(['title' => '100 remote']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'title', '100% remote'); + + expect($query->pluck('title')->all())->toBe(['100 remote']); +})->group('integration'); + +it('treats an underscore as a literal in a not contains clause', function (): void { + Document::create(['title' => 'data_engineer']); + Document::create(['title' => 'data engineer']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'title', 'data_engineer'); + + expect($query->pluck('title')->all())->toBe(['data engineer']); +})->group('integration'); + +it('treats a backslash as a literal in a not contains clause', function (): void { + Document::create(['title' => 'path\\to']); + Document::create(['title' => 'pathto']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'title', 'path\\to'); + + expect($query->pluck('title')->all())->toBe(['pathto']); +})->group('integration'); + +it('ands consecutive not contains clauses', function (): void { + Document::create(['title' => 'senior engineer']); + Document::create(['title' => 'lead engineer']); + Document::create(['title' => 'engineer']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'title', 'senior'); + LikeOperator::applyNotContains($query, 'title', 'lead'); + + expect($query->pluck('title')->all())->toBe(['engineer']); +})->group('integration'); + +it('excludes a null column from a not contains clause', function (): void { + Document::create(['title' => 'a', 'summary' => null]); + Document::create(['title' => 'b', 'summary' => 'remote']); + Document::create(['title' => 'c', 'summary' => 'office']); + + $query = Document::query(); + LikeOperator::applyNotContains($query, 'summary', 'remote'); + + expect($query->pluck('title')->all())->toBe(['c']); +})->group('integration'); + +it('keeps a null column when grouped with or where null', function (): void { + Document::create(['title' => 'a', 'summary' => null]); + Document::create(['title' => 'b', 'summary' => 'remote']); + Document::create(['title' => 'c', 'summary' => 'office']); + + $query = Document::query()->where(function (Builder $query): void { + $query->whereNull('summary'); + LikeOperator::orApplyNotContains($query, 'summary', 'remote'); + }); + + expect($query->orderBy('id')->pluck('title')->all())->toBe(['a', 'c']); +})->group('integration'); + +it('adds a not contains clause with or instead of and', function (): void { + Document::create(['title' => 'senior engineer', 'summary' => 'keep']); + Document::create(['title' => 'senior engineer', 'summary' => 'drop']); + Document::create(['title' => 'junior engineer', 'summary' => 'drop']); + + $query = Document::query()->where(function (Builder $query): void { + $query->where('summary', 'keep'); + LikeOperator::orApplyNotContains($query, 'title', 'senior'); + }); + + expect($query->orderBy('id')->pluck('title')->all())->toBe(['senior engineer', 'junior engineer']) + ->and($query->pluck('summary')->sort()->values()->all())->toBe(['drop', 'keep']); +})->group('integration'); + +it('accepts the boolean and the negation as arguments', function (): void { + Document::create(['title' => 'senior engineer']); + Document::create(['title' => 'junior engineer']); + Document::create(['title' => 'designer']); + + $query = Document::query()->where(function (Builder $query): void { + LikeOperator::applyContains($query, 'title', 'engineer', 'or', true); + LikeOperator::applyContains($query, 'title', 'junior', 'or'); + }); + + expect($query->orderBy('id')->pluck('title')->all())->toBe(['junior engineer', 'designer']); +})->group('integration'); + +it('excludes a substring of a date column', function (): void { + Document::create(['title' => 'a', 'issued_on' => '2019-07-14']); + Document::create(['title' => 'b', 'issued_on' => '2020-07-14']); + + $query = Document::query(); + LikeOperator::applyContainsOnDate($query, 'issued_on', '2019', 'and', true); + + expect($query->pluck('title')->all())->toBe(['b']); +})->group('integration'); + +it('matches a substring of a date column with or', function (): void { + Document::create(['title' => 'a', 'issued_on' => '2019-07-14']); + Document::create(['title' => 'b', 'issued_on' => '2020-07-14']); + Document::create(['title' => 'c', 'issued_on' => '2021-07-14']); + + $query = Document::query()->where(function (Builder $query): void { + LikeOperator::applyContainsOnDate($query, 'issued_on', '2019', 'or'); + LikeOperator::applyContainsOnDate($query, 'issued_on', '2021', 'or'); + }); + + expect($query->orderBy('id')->pluck('title')->all())->toBe(['a', 'c']); +})->group('integration'); + +it('rejects a boolean other than and or or', function (): void { + LikeOperator::applyContains(Document::query(), 'title', 'x', 'xor'); +})->throws(InvalidArgumentException::class, "The boolean must be 'and' or 'or', got 'xor'."); diff --git a/workbench/app/Models/Document.php b/workbench/app/Models/Document.php index e2c3e21..5999222 100644 --- a/workbench/app/Models/Document.php +++ b/workbench/app/Models/Document.php @@ -8,6 +8,7 @@ /** * @property string $title + * @property string|null $summary * @property string|null $issued_on * @property string|null $recorded_at * @property string|null $from diff --git a/workbench/database/migrations/0001_01_01_000000_create_documents_table.php b/workbench/database/migrations/0001_01_01_000000_create_documents_table.php index 5b45399..c27211d 100644 --- a/workbench/database/migrations/0001_01_01_000000_create_documents_table.php +++ b/workbench/database/migrations/0001_01_01_000000_create_documents_table.php @@ -13,6 +13,7 @@ public function up(): void Schema::create('documents', function (Blueprint $table): void { $table->increments('id'); $table->string('title'); + $table->string('summary')->nullable(); $table->date('issued_on')->nullable(); $table->timestamp('recorded_at')->nullable(); // `from` e' una parola riservata in MySQL e PostgreSQL: serve a From fe98bc5420cc28b75b9d4d17e32835ed7536d803 Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Thu, 17 Sep 2026 15:58:41 +0200 Subject: [PATCH 2/2] docs(readme): document or and negated like variants --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 90e2fd4..029041f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ There is nothing else to do. No service provider to register, no config to publi `LikeOperator::applyContains()` adds a wildcard safe `LIKE` (or `ILIKE` on PostgreSQL) clause to a query. -`applyContains()` and `applyContainsOnDate()` type hint `Illuminate\Database\Eloquent\Builder`. They can be called on an Eloquent builder, and inside a closure that Laravel hands one, such as the closure passed to `Eloquent\Builder::where()`. They cannot be called on a plain `Illuminate\Database\Query\Builder`, or inside a closure that receives one (for example the closure passed to `orWhereIn()`, `whereExists()` or `Query\Builder::from()`). Accepting both builder types is a known limitation, deferred to a later release. +`applyContains()`, its OR and negated variants, and `applyContainsOnDate()` type hint `Illuminate\Database\Eloquent\Builder`. They can be called on an Eloquent builder, and inside a closure that Laravel hands one, such as the closure passed to `Eloquent\Builder::where()`. They cannot be called on a plain `Illuminate\Database\Query\Builder`, or inside a closure that receives one (for example the closure passed to `orWhereIn()`, `whereExists()` or `Query\Builder::from()`). Accepting both builder types is a known limitation, deferred to a later release. ```php use Illuminate\Database\Eloquent\Builder; @@ -49,6 +49,50 @@ Movie::query() `applyContains()` wraps the column through the query's grammar, picks the operator with `LikeOperator::for()`, builds the pattern with `LikeOperator::containsPattern()` and issues one `whereRaw()` call with the correct `ESCAPE` clause for the driver. `applyContainsOnDate()` does the same thing but first casts the date column to text in the right dialect (`::text` on PostgreSQL, `CAST(... AS CHAR)` on MySQL and MariaDB, `CAST(... AS TEXT)` elsewhere), for matching a partial date, month or year that is displayed rather than compared. +### OR and negated variants + +`applyContains()` accepts two optional arguments that mirror Laravel's own `whereLike()`: `$boolean` (`'and'` by default, or `'or'`) and `$not` (`false` by default). `applyContainsOnDate()` accepts the same two. For readability `applyContains()` also has three named shortcuts: + +| Method | Clause added | +| --- | --- | +| `applyContains($query, $column, $term)` | `and LIKE ?` | +| `orApplyContains($query, $column, $term)` | `or LIKE ?` | +| `applyNotContains($query, $column, $term)` | `and NOT LIKE ?` | +| `orApplyNotContains($query, $column, $term)` | `or NOT LIKE ?` | + +On PostgreSQL the operator is `ILIKE` or `NOT ILIKE`. Escaping and the `ESCAPE` clause are identical in all four. Any `$boolean` other than `'and'` or `'or'` (case insensitive) throws an `InvalidArgumentException`. + +An OR clause joins whatever came before it in the same `where` group, so wrap OR clauses in a closure to keep them from leaking into the rest of the query: + +```php +use Illuminate\Database\Eloquent\Builder; +use PlinCode\SqlDialect\LikeOperator; + +Job::query() + ->where(function (Builder $query) use ($keywords) { + foreach ($keywords as $keyword) { + LikeOperator::orApplyContains($query, 'title', $keyword); + } + }) + ->where(function (Builder $query) use ($excluded) { + foreach ($excluded as $keyword) { + LikeOperator::applyNotContains($query, 'title', $keyword); + } + }) + ->get(); +``` + +`NULL` follows SQL's three valued logic and the package does not change it. `NULL LIKE '%x%'` and `NULL NOT LIKE '%x%'` both evaluate to `NULL`, so a row whose column is `NULL` is left out by the negated variants as well as by the positive ones. When those rows should be kept, add the null check yourself: + +```php +$query->where(function (Builder $query) { + $query->whereNull('location'); + LikeOperator::orApplyNotContains($query, 'location', 'onsite'); +}); +``` + +### Escaping + `containsPattern()` (and the `escapeWildcards()` it calls) neutralise `%`, `_` and `\` in the search term with `addcslashes()`, so a term containing those characters is matched literally instead of being interpreted as a wildcard. That is why `applyContains()` always appends an `ESCAPE` clause: it tells the driver which character in the pattern is the escape character it just used. `$column` is interpolated straight into the raw SQL through the connection's grammar and must be a column name your own code supplies, never request input; `$term`, the search value, is always passed as a bound parameter. `Grammar::wrap()` quotes identifiers, it does not validate or escape arbitrary strings, so it is not a safeguard against passing user input as `$column`.