Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <column> LIKE ?` |
| `orApplyContains($query, $column, $term)` | `or <column> LIKE ?` |
| `applyNotContains($query, $column, $term)` | `and <column> NOT LIKE ?` |
| `orApplyNotContains($query, $column, $term)` | `or <column> 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`.
Expand Down
71 changes: 57 additions & 14 deletions src/LikeOperator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;

final class LikeOperator
{
Expand All @@ -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<covariant \Illuminate\Database\Eloquent\Model> $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<covariant \Illuminate\Database\Eloquent\Model> $query
*/
public static function orApplyContains(Builder $query, string $column, string $term): void
{
self::applyContains($query, $column, $term, 'or');
}

/**
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $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<covariant \Illuminate\Database\Eloquent\Model> $query
*/
public static function orApplyNotContains(Builder $query, string $column, string $term): void
{
self::applyContains($query, $column, $term, 'or', true);
}

/**
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $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<covariant \Illuminate\Database\Eloquent\Model> $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);
}

/**
Expand Down
171 changes: 171 additions & 0 deletions tests/LikeOperatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use Illuminate\Database\Eloquent\Builder;
use PlinCode\SqlDialect\LikeOperator;
use Workbench\App\Models\Document;

Expand Down Expand Up @@ -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'.");
1 change: 1 addition & 0 deletions workbench/app/Models/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down