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
47 changes: 27 additions & 20 deletions src/TableBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,27 +93,34 @@ public function rowsQuery()
});

if ($this->searchBy && $this->searchBy !== '') {
foreach ($this->getSearchableColumns() as $column) {
// Explode the column to separate relationships and the actual column name
$parts = explode('.', $column);

// Extract the actual column name from the end of the array
$actualColumnName = array_pop($parts);

// If there are relationships defined (implied by remaining elements in $parts)
if (! empty($parts)) {
// Build the relationship string from the remaining $parts
$relationshipPath = implode('.', $parts);

// Use a closure to apply the search condition on the related model
$query->orWhereHas($relationshipPath, function ($query) use ($actualColumnName) {
$query->where($actualColumnName, 'like', '%'.$this->searchBy.'%');
});
} else {
// If there are no relationships, directly apply the search condition on the current model
$query->orWhere($actualColumnName, 'like', '%'.$this->searchBy.'%');
// Grouped, so the searchable columns OR against each other and AND against
// everything else. Left ungrouped these were bare top-level orWheres applied
// after query(), which meant any report with real constraints discarded them
// as soon as someone typed — search widened the result set instead of
// narrowing it, and no report could defend against it from inside query().
$query->where(function ($query) {
foreach ($this->getSearchableColumns() as $column) {
// Explode the column to separate relationships and the actual column name
$parts = explode('.', $column);

// Extract the actual column name from the end of the array
$actualColumnName = array_pop($parts);

// If there are relationships defined (implied by remaining elements in $parts)
if (! empty($parts)) {
// Build the relationship string from the remaining $parts
$relationshipPath = implode('.', $parts);

// Use a closure to apply the search condition on the related model
$query->orWhereHas($relationshipPath, function ($query) use ($actualColumnName) {
$query->where($actualColumnName, 'like', '%'.$this->searchBy.'%');
});
} else {
// If there are no relationships, directly apply the search condition on the current model
$query->orWhere($actualColumnName, 'like', '%'.$this->searchBy.'%');
}
}
}
});
}

$dottedFilterValue = Arr::dot($this->filterValues);
Expand Down
87 changes: 87 additions & 0 deletions tests/TableBuilderSearchScopingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

use ACTTraining\QueryBuilder\Support\Columns\Column;
use ACTTraining\QueryBuilder\TableBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Schema;

/**
* The search box must narrow a report, never widen it.
*
* rowsQuery() appended each searchable column as a bare top-level orWhere, after
* everything query() returned, so the predicate became
* `(report constraints) AND ... OR column LIKE '%term%'` and any report with real
* constraints discarded them the moment someone typed (#3515 in ACT-Training/people).
* Nothing inside query() could defend against it: the orWhere sat outside any group
* the report created.
*/
class SearchScopingRecord extends Model
{
protected $table = 'search_scoping_records';

protected $guarded = [];

public $timestamps = false;
}

class SearchScopingTable extends TableBuilder
{
public function query(): Builder
{
return SearchScopingRecord::query()->where('category', 'health-and-safety');
}

public function columns(): array
{
return [
Column::make('Title', 'title')->searchable(),
];
}

public function filters(): array
{
return [];
}
}

beforeEach(function () {
Schema::create('search_scoping_records', function ($table) {
$table->id();
$table->string('category');
$table->string('title');
});

SearchScopingRecord::insert([
['category' => 'health-and-safety', 'title' => 'Fire Safety Awareness'],
['category' => 'health-and-safety', 'title' => 'Manual Handling'],
['category' => 'marketing', 'title' => 'Fire up your Facebook page'],
['category' => 'marketing', 'title' => 'Content strategy'],
]);
});

it('returns only the constrained rows when nothing is searched', function () {
$table = new SearchScopingTable;

expect($table->rowsQuery()->pluck('title')->all())
->toEqualCanonicalizing(['Fire Safety Awareness', 'Manual Handling']);
});

it('keeps the query constraints when a search term is typed', function () {
$table = new SearchScopingTable;
$table->searchBy = 'Fire';

// Without grouping this returns the marketing row too, because the search ORs
// past `category = health-and-safety`.
expect($table->rowsQuery()->pluck('title')->all())
->toEqual(['Fire Safety Awareness']);
});

it('never returns more rows with a search term than without one', function () {
$unsearched = (new SearchScopingTable)->rowsQuery()->count();

$table = new SearchScopingTable;
$table->searchBy = 'a';

expect($table->rowsQuery()->count())->toBeLessThanOrEqual($unsearched);
});
Loading