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
4 changes: 4 additions & 0 deletions docs/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export default defineUserConfig({
text: "Features",
collapsible: true,
children: [
{
text: "Lifecycle Hooks",
link: "features/lifecycle-hooks",
},
{
text: "Header-Driven Filter Mode",
link: "features/header-driven-filter-mode",
Expand Down
78 changes: 78 additions & 0 deletions docs/features/lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Lifecycle Hooks

The `Filterable` base class provides two lifecycle hooks that let you modify the query builder **before** and **after** filters are applied.

These hooks are **optional** and can be defined directly inside your filter class.

---

#### **`initially()`**

Runs **before any filters are executed**.
It’s perfect for setting up default query conditions or preparing the builder.

```php
use Kettasoft\Filterable\Filterable;
use Illuminate\Database\Eloquent\Builder;

class ProductFilter extends Filterable
{
protected function initially(Builder $builder): void
{
// Example: Apply a global condition before filtering
$builder->where('is_active', true);
}
}
```

---

#### **`finally()`**

Runs **after all filters have been processed**.
It allows you to finalize or clean up your query logic.

```php
protected function finally(Builder $builder): void
{
// Example: Apply a default sort order
if (! $builder->getQuery()->orders) {
$builder->orderBy('created_at', 'desc');
}
}
```

---

#### ⚙️ How It Works

- `initially()` is invoked **right before** any filter method runs.
- `finally()` is called **after** all filter methods have finished.
- Both are **optional** — if not defined, they’re skipped automatically.
- They share the same `$builder` instance used by the engine, so any change persists through the filtering process.

---

#### 🪄 CLI Integration

When generating a new filter using the Artisan command:

```bash
php artisan make:filter ProductFilter
```

Both `initially()` and `finally()` methods are automatically added to the stub file, ready for customization.

---

#### 💡 Practical Use Cases

- Use `initially()` to:

- Apply global constraints (`is_active = true`, `tenant_id = currentTenant()`).
- Add necessary joins or eager loads before filters run.

- Use `finally()` to:

- Apply ordering or limits.
- Clean up relations or add post-filter transformations.
28 changes: 26 additions & 2 deletions src/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ public function apply(Builder|null $builder = null): Invoker|Builder

$builder = $this->initQueryBuilderInstance($builder);

$this->builder = $builder;
$this->builder = $this->initially($builder);

$builder = Executer::execute($this->engine, $builder);

Expand All @@ -324,7 +324,7 @@ public function apply(Builder|null $builder = null): Invoker|Builder
return $builder;
}

$invoker = new Invoker($builder);
$invoker = new Invoker($this->finally($builder));

// Pass caching settings to invoker
if ($this->isCachingEnabled()) {
Expand Down Expand Up @@ -354,6 +354,30 @@ public function apply(Builder|null $builder = null): Invoker|Builder
}
}

/**
* Finalize the query builder after all filters have been applied.
*
* @param Builder $builder
* @return Builder
*/
protected function finally(Builder $builder): Builder
{
// Custom finalization logic can be added here
return $builder;
}

/**
* Initial processing of the query builder before applying filters.
*
* @param Builder $builder
* @return Builder
*/
protected function initially(Builder $builder): Builder
{
// Custom initial logic can be added here
return $builder;
}

/**
* Create and return a new Filterable instance after applying the given callback.
*
Expand Down
22 changes: 22 additions & 0 deletions stubs/filter.stub
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,26 @@ class $$CLASS$$ extends Filterable
protected $filters = [$$FILTER_KEYS$$];

$$METHODS$$

/**
* Initial processing of the query builder before applying filters.
*
* @param Builder $builder
* @return Builder
*/
protected function initially(Builder $builder): Builder
{
return $builder;
}

/**
* Finalize the query builder after all filters have been applied.
*
* @param Builder $builder
* @return Builder
*/
protected function finally(Builder $builder): Builder
{
return $builder;
}
}
86 changes: 86 additions & 0 deletions tests/Feature/Filterable/FilterableLifecycleHooksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace Kettasoft\Filterable\Tests\Feature\Filterable;

use Kettasoft\Filterable\Filterable;
use Kettasoft\Filterable\Tests\TestCase;
use Illuminate\Database\Eloquent\Builder;
use Kettasoft\Filterable\Tests\Models\Post;

class FilterableLifecycleHooksTest extends TestCase
{
public function test_it_can_trigger_before_filtering_hook()
{
$filter = new class extends Filterable {
protected function initially(Builder $builder): Builder
{
return $builder->where('id', '>', 10);
}
};

$invoker = Post::filter($filter);

$this->assertStringContainsString('where "id" > ?', $invoker->toSql());
}

public function test_it_can_trigger_after_filtering_hook()
{
$filter = new class extends Filterable {
protected function finally(Builder $builder): Builder
{
return $builder->where('id', '>', 10);
}
};

$invoker = Post::filter($filter);

$this->assertStringContainsString('where "id" > ?', $invoker->toSql());
}

public function test_it_can_trigger_initially_with_finally_hook()
{
$filter = new class extends Filterable {
protected function initially(Builder $builder): Builder
{
return $builder->where('id', '>', 10);
}

protected function finally(Builder $builder): Builder
{
return $builder->where('status', '=', 'published');
}
};

$invoker = Post::filter($filter);

$this->assertStringContainsString('where "id" > ? and "status" = ?', $invoker->toSql());
}

public function test_it_can_trigger_initially_with_request_filters_and_finally_hook()
{
$filter = new class extends Filterable {
protected $filters = ['title'];
protected function initially(Builder $builder): Builder
{
return $builder->where('id', '>', 10);
}

protected function finally(Builder $builder): Builder
{
return $builder->where('status', '=', 'published');
}

public function title($payload)
{
return $this->builder->where('title', '=', $payload->value);
}
};

// Simulate request filters
$this->app['request']->query->set('title', 'Test Post');

$invoker = Post::filter($filter);

$this->assertStringContainsString('where "id" > ? and "title" = ? and "status" = ?', $invoker->toSql());
}
}