Skip to content

Commit 3c7d022

Browse files
authored
Merge pull request #33 from kettasoft/feat/filter-lifecycle-hooks
Add `initially` and `finally` lifecycle hooks to Filters + Update Stub Generation
2 parents 962cf11 + a2004a4 commit 3c7d022

File tree

5 files changed

+216
-2
lines changed

5 files changed

+216
-2
lines changed

docs/.vuepress/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export default defineUserConfig({
9595
text: "Features",
9696
collapsible: true,
9797
children: [
98+
{
99+
text: "Lifecycle Hooks",
100+
link: "features/lifecycle-hooks",
101+
},
98102
{
99103
text: "Header-Driven Filter Mode",
100104
link: "features/header-driven-filter-mode",

docs/features/lifecycle-hooks.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Lifecycle Hooks
2+
3+
The `Filterable` base class provides two lifecycle hooks that let you modify the query builder **before** and **after** filters are applied.
4+
5+
These hooks are **optional** and can be defined directly inside your filter class.
6+
7+
---
8+
9+
#### **`initially()`**
10+
11+
Runs **before any filters are executed**.
12+
It’s perfect for setting up default query conditions or preparing the builder.
13+
14+
```php
15+
use Kettasoft\Filterable\Filterable;
16+
use Illuminate\Database\Eloquent\Builder;
17+
18+
class ProductFilter extends Filterable
19+
{
20+
protected function initially(Builder $builder): void
21+
{
22+
// Example: Apply a global condition before filtering
23+
$builder->where('is_active', true);
24+
}
25+
}
26+
```
27+
28+
---
29+
30+
#### **`finally()`**
31+
32+
Runs **after all filters have been processed**.
33+
It allows you to finalize or clean up your query logic.
34+
35+
```php
36+
protected function finally(Builder $builder): void
37+
{
38+
// Example: Apply a default sort order
39+
if (! $builder->getQuery()->orders) {
40+
$builder->orderBy('created_at', 'desc');
41+
}
42+
}
43+
```
44+
45+
---
46+
47+
#### ⚙️ How It Works
48+
49+
- `initially()` is invoked **right before** any filter method runs.
50+
- `finally()` is called **after** all filter methods have finished.
51+
- Both are **optional** — if not defined, they’re skipped automatically.
52+
- They share the same `$builder` instance used by the engine, so any change persists through the filtering process.
53+
54+
---
55+
56+
#### 🪄 CLI Integration
57+
58+
When generating a new filter using the Artisan command:
59+
60+
```bash
61+
php artisan make:filter ProductFilter
62+
```
63+
64+
Both `initially()` and `finally()` methods are automatically added to the stub file, ready for customization.
65+
66+
---
67+
68+
#### 💡 Practical Use Cases
69+
70+
- Use `initially()` to:
71+
72+
- Apply global constraints (`is_active = true`, `tenant_id = currentTenant()`).
73+
- Add necessary joins or eager loads before filters run.
74+
75+
- Use `finally()` to:
76+
77+
- Apply ordering or limits.
78+
- Clean up relations or add post-filter transformations.

src/Filterable.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ public function apply(Builder|null $builder = null): Invoker|Builder
307307

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

310-
$this->builder = $builder;
310+
$this->builder = $this->initially($builder);
311311

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

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

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

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

357+
/**
358+
* Finalize the query builder after all filters have been applied.
359+
*
360+
* @param Builder $builder
361+
* @return Builder
362+
*/
363+
protected function finally(Builder $builder): Builder
364+
{
365+
// Custom finalization logic can be added here
366+
return $builder;
367+
}
368+
369+
/**
370+
* Initial processing of the query builder before applying filters.
371+
*
372+
* @param Builder $builder
373+
* @return Builder
374+
*/
375+
protected function initially(Builder $builder): Builder
376+
{
377+
// Custom initial logic can be added here
378+
return $builder;
379+
}
380+
357381
/**
358382
* Create and return a new Filterable instance after applying the given callback.
359383
*

stubs/filter.stub

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,26 @@ class $$CLASS$$ extends Filterable
1616
protected $filters = [$$FILTER_KEYS$$];
1717

1818
$$METHODS$$
19+
20+
/**
21+
* Initial processing of the query builder before applying filters.
22+
*
23+
* @param Builder $builder
24+
* @return Builder
25+
*/
26+
protected function initially(Builder $builder): Builder
27+
{
28+
return $builder;
29+
}
30+
31+
/**
32+
* Finalize the query builder after all filters have been applied.
33+
*
34+
* @param Builder $builder
35+
* @return Builder
36+
*/
37+
protected function finally(Builder $builder): Builder
38+
{
39+
return $builder;
40+
}
1941
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace Kettasoft\Filterable\Tests\Feature\Filterable;
4+
5+
use Kettasoft\Filterable\Filterable;
6+
use Kettasoft\Filterable\Tests\TestCase;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Kettasoft\Filterable\Tests\Models\Post;
9+
10+
class FilterableLifecycleHooksTest extends TestCase
11+
{
12+
public function test_it_can_trigger_before_filtering_hook()
13+
{
14+
$filter = new class extends Filterable {
15+
protected function initially(Builder $builder): Builder
16+
{
17+
return $builder->where('id', '>', 10);
18+
}
19+
};
20+
21+
$invoker = Post::filter($filter);
22+
23+
$this->assertStringContainsString('where "id" > ?', $invoker->toSql());
24+
}
25+
26+
public function test_it_can_trigger_after_filtering_hook()
27+
{
28+
$filter = new class extends Filterable {
29+
protected function finally(Builder $builder): Builder
30+
{
31+
return $builder->where('id', '>', 10);
32+
}
33+
};
34+
35+
$invoker = Post::filter($filter);
36+
37+
$this->assertStringContainsString('where "id" > ?', $invoker->toSql());
38+
}
39+
40+
public function test_it_can_trigger_initially_with_finally_hook()
41+
{
42+
$filter = new class extends Filterable {
43+
protected function initially(Builder $builder): Builder
44+
{
45+
return $builder->where('id', '>', 10);
46+
}
47+
48+
protected function finally(Builder $builder): Builder
49+
{
50+
return $builder->where('status', '=', 'published');
51+
}
52+
};
53+
54+
$invoker = Post::filter($filter);
55+
56+
$this->assertStringContainsString('where "id" > ? and "status" = ?', $invoker->toSql());
57+
}
58+
59+
public function test_it_can_trigger_initially_with_request_filters_and_finally_hook()
60+
{
61+
$filter = new class extends Filterable {
62+
protected $filters = ['title'];
63+
protected function initially(Builder $builder): Builder
64+
{
65+
return $builder->where('id', '>', 10);
66+
}
67+
68+
protected function finally(Builder $builder): Builder
69+
{
70+
return $builder->where('status', '=', 'published');
71+
}
72+
73+
public function title($payload)
74+
{
75+
return $this->builder->where('title', '=', $payload->value);
76+
}
77+
};
78+
79+
// Simulate request filters
80+
$this->app['request']->query->set('title', 'Test Post');
81+
82+
$invoker = Post::filter($filter);
83+
84+
$this->assertStringContainsString('where "id" > ? and "title" = ? and "status" = ?', $invoker->toSql());
85+
}
86+
}

0 commit comments

Comments
 (0)