diff --git a/config/laravilt-query-builder.php b/config/laravilt-query-builder.php index adb22f0..93ccd11 100644 --- a/config/laravilt-query-builder.php +++ b/config/laravilt-query-builder.php @@ -10,7 +10,8 @@ | */ - 'enabled' => env('LARAVILT_QUERY-BUILDER_ENABLED', true), + // LARAVILT_QUERY-BUILDER_ENABLED is the pre-1.1 key, still honoured for existing .env files + 'enabled' => env('LARAVILT_QUERY_BUILDER_ENABLED', env('LARAVILT_QUERY-BUILDER_ENABLED', true)), // Add your configuration options here ]; diff --git a/src/Commands/InstallQueryBuilderCommand.php b/src/Commands/InstallQueryBuilderCommand.php index 0b41e1b..0d8da82 100644 --- a/src/Commands/InstallQueryBuilderCommand.php +++ b/src/Commands/InstallQueryBuilderCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Process; class InstallQueryBuilderCommand extends Command { @@ -24,7 +25,7 @@ class InstallQueryBuilderCommand extends Command */ public function handle(): int { - $this->info('Installing {{ name }} plugin...'); + $this->info('Installing QueryBuilder plugin...'); $this->newLine(); // Publish config @@ -35,7 +36,7 @@ public function handle(): int $this->buildAssets(); } $this->newLine(); - $this->info('✅ {{ name }} plugin installed successfully!'); + $this->info('✅ QueryBuilder plugin installed successfully!'); $this->newLine(); return self::SUCCESS; @@ -48,7 +49,7 @@ protected function publishConfig(): void { $this->info('Publishing configuration...'); - $params = ['--tag' => '{{ config }}-config']; + $params = ['--tag' => 'laravilt-query-builder-config']; if ($this->option('force')) { $params['--force'] = true; diff --git a/src/Filters/DateFilter.php b/src/Filters/DateFilter.php index e21a80f..3bb2190 100644 --- a/src/Filters/DateFilter.php +++ b/src/Filters/DateFilter.php @@ -73,11 +73,16 @@ protected function applyDefault(Builder $query, mixed $value): void { $column = $this->getColumn(); - if ($this->operator === 'between' && is_array($value) && count($value) === 2) { - $query->whereBetween($column, $value); - } else { - $query->where($column, $this->operator, $value); + if ($this->operator === 'between') { + // `where($column, 'between', ...)` is invalid SQL, so an incomplete range is ignored + if (is_array($value) && count($value) === 2) { + $query->whereBetween($column, array_values($value)); + } + + return; } + + $query->where($column, $this->operator, $value); } /** diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 2313b23..307fd71 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -85,7 +85,8 @@ public function sortBy(?string $column, ?string $direction = 'asc'): static { $this->sortBy = $column; // Validate sort direction - $this->sortDirection = in_array($direction, ['asc', 'desc']) ? $direction : 'asc'; + $direction = $direction === null ? null : strtolower($direction); + $this->sortDirection = in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc'; return $this; } @@ -114,7 +115,8 @@ public function apply(Builder $query): Builder foreach ($this->filters as $filter) { $value = $this->filterValues[$filter->getName()] ?? null; - if ($value !== null && $value !== '') { + // A cleared multi-select arrives as [], which would otherwise become `whereIn(col, [])` and match nothing + if ($value !== null && $value !== '' && $value !== []) { $filter->apply($query, $value); } } @@ -126,12 +128,36 @@ public function apply(Builder $query): Builder // Apply sorting if ($this->sortBy !== null) { - $query->orderBy($this->sortBy, $this->sortDirection ?? 'asc'); + $column = $this->resolveSortColumn($this->sortBy); + + if ($column !== null) { + $query->orderBy($column, $this->sortDirection ?? 'asc'); + } } return $query; } + /** + * Map the requested sort to its column. When sorts are registered they act as an + * allow-list (the request carries the Sort name, which may differ from its column); + * without registered sorts the value is used as the column directly. + */ + protected function resolveSortColumn(string $sortBy): ?string + { + if ($this->sorts === []) { + return $sortBy; + } + + foreach ($this->sorts as $sort) { + if ($sort->getName() === $sortBy) { + return $sort->getColumn(); + } + } + + return null; + } + /** * @param Builder $query */ diff --git a/tests/Unit/QueryBuilderApplyTest.php b/tests/Unit/QueryBuilderApplyTest.php new file mode 100644 index 0000000..6a28298 --- /dev/null +++ b/tests/Unit/QueryBuilderApplyTest.php @@ -0,0 +1,79 @@ +id(); + $table->string('title'); + $table->string('category'); + $table->timestamp('published_at')->nullable(); + }); + + $this->model = new class extends Model + { + protected $table = 'qb_apply_items'; + + protected $guarded = []; + + public $timestamps = false; + }; + + $this->model::create(['title' => 'B', 'category' => 'news', 'published_at' => '2024-01-01']); + $this->model::create(['title' => 'A', 'category' => 'tips', 'published_at' => '2024-02-01']); + $this->model::create(['title' => 'C', 'category' => 'news', 'published_at' => '2024-03-01']); +}); + +afterEach(function () { + Schema::dropIfExists('qb_apply_items'); +}); + +test('sorts by the registered sort column rather than its name', function () { + $query = (new QueryBuilder) + ->sorts([Sort::make('headline', 'title')]) + ->sortBy('headline', 'desc') + ->apply($this->model::query()); + + expect($query->pluck('title')->all())->toBe(['C', 'B', 'A']); +}); + +test('ignores a sort that is not registered', function () { + $query = (new QueryBuilder) + ->sorts([Sort::make('title')]) + ->sortBy('category', 'desc') + ->apply($this->model::query()); + + expect($query->toBase()->orders)->toBeNull(); +}); + +test('accepts an upper-case sort direction', function () { + $builder = (new QueryBuilder)->sortBy('title', 'DESC'); + + expect($builder->toInertiaProps()['sortDirection'])->toBe('desc') + ->and($builder->apply($this->model::query())->pluck('title')->all())->toBe(['C', 'B', 'A']); +}); + +test('ignores a cleared multi-select filter', function () { + $query = (new QueryBuilder) + ->filters([SelectFilter::make('category')->multiple()]) + ->applyFilters(['category' => []]) + ->apply($this->model::query()); + + expect($query->count())->toBe(3); +}); + +test('ignores an incomplete between date range instead of building invalid sql', function () { + $query = $this->model::query(); + + DateFilter::make('published_at')->between()->apply($query, '2024-01-15'); + + expect($query->count())->toBe(3); +});