From 223be97ca80104f89806037f027f533009caf87f Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:37:02 +0200 Subject: [PATCH 01/13] feat: support custom make-filter path and namespace (#48) --- README.md | 8 ++ config/filterable.php | 8 +- docs/cli/setup.md | 12 ++- src/Commands/MakeFilterCommand.php | 87 +++++++++++++++---- .../Commands/MakeFilterCommandTest.php | 35 +++++++- 5 files changed, 128 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5760c1d..43c07e6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ Add the following line to the providers array in config/app.php or bootstrap/pro php artisan filterable:make-filter PostFilter --filters=title,status ``` +To generate the class in a custom namespace or directory: + +```bash +php artisan filterable:make-filter PostFilter \ + --namespace="Modules\\Blog\\App\\Filters" \ + --path="Modules/Blog/app/Filters" +``` + **2. Define your filters** ```php diff --git a/config/filterable.php b/config/filterable.php index 84e11ba..6551af2 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -8,7 +8,9 @@ | Eloquent Filter Settings |-------------------------------------------------------------------------- | - | This is the namespace all you Eloquent Model Filters will reside + | This is the default namespace used when generating new filter classes. + | You can override it per command with: + | php artisan filterable:make-filter UserFilter --namespace="Modules\Blog\App\Filters" | */ 'namespace' => 'App\\Http\\Filters', @@ -18,7 +20,9 @@ | Path of saving new filters |-------------------------------------------------------------------------- | - | This is the namespace all you Eloquent Model Filters will reside + | This is the default directory used when creating new filter files. + | You can override it per command with: + | php artisan filterable:make-filter UserFilter --path="Modules/Blog/app/Filters" | */ 'save_filters_at' => app_path('Http/Filters'), diff --git a/docs/cli/setup.md b/docs/cli/setup.md index 90b07e0..c1aafdd 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -57,6 +57,15 @@ When executed, this command will: php artisan filterable:make-filter PostFilter --filters=author,title ``` + If you need to generate a filter in a custom location, you can override the + default target at generation time: + + ```bash + php artisan filterable:make-filter PostFilter \ + --namespace="Modules\\Blog\\App\\Filters" \ + --path="Modules/Blog/app/Filters" + ``` + --- ### **Example Output** @@ -67,7 +76,7 @@ When executed, this command will: 📁 Created directory: app/Http/Filters 🎉 Setup complete! You can now create your first filter with: -php artisan filterable:make PostFilter --filters=test +php artisan filterable:make-filter PostFilter --filters=test ``` --- @@ -76,6 +85,7 @@ php artisan filterable:make PostFilter --filters=test - Use the `--force` flag if you want to **re-publish** the configuration file and overwrite existing settings. - The command automatically detects whether the `app/Http/Filters` directory already exists. +- `filterable:make-filter` uses the defaults from `config/filterable.php`, but you can override them per run with `--namespace` and `--path`. --- diff --git a/src/Commands/MakeFilterCommand.php b/src/Commands/MakeFilterCommand.php index dd87d13..10e7967 100644 --- a/src/Commands/MakeFilterCommand.php +++ b/src/Commands/MakeFilterCommand.php @@ -5,14 +5,15 @@ use Illuminate\Support\Str; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Config; use Kettasoft\Filterable\Support\Stub; class MakeFilterCommand extends Command { - protected $signature = 'filterable:make-filter - {name : The filter class name} + protected $signature = 'filterable:make-filter + {name : The filter class name} {--filters= : Comma-separated filter methods (e.g. status,title)} + {--namespace= : Override the generated class namespace} + {--path= : Override the directory where the filter file will be created} {--force : Overwrite existing filter if it exists}'; protected $description = 'Create a new Eloquent filter class'; @@ -20,19 +21,22 @@ class MakeFilterCommand extends Command public function handle() { $name = trim($this->argument('name')); + $class = $this->resolveClassName($name); $keys = $this->option('filters'); + $savePath = $this->getFilterSavingPath(); + $namespace = $this->getFilterNamespace(); + $filePath = $savePath . "/{$class}.php"; Stub::setBasePath(config('filterable.generator.stubs')); // Ensure directory exists - $savePath = $this->getFilterSavingPath(); if (!File::exists($savePath)) { File::makeDirectory($savePath, 0755, true); } // Prevent overwriting existing files - if (File::exists($savePath . "/{$name}.php") && !$this->option('force')) { - $this->error("❌ Filter class '{$name}.php' already exists at {$savePath}."); + if (File::exists($filePath) && !$this->option('force')) { + $this->error("❌ Filter class '{$class}.php' already exists at {$savePath}."); $this->warn('Use the --force option to overwrite it.'); return Command::FAILURE; } @@ -40,13 +44,13 @@ public function handle() // If no filters provided → create simple class if (!$keys) { Stub::create('filter.stub', [ - 'CLASS' => $name, + 'CLASS' => $class, 'FILTER_KEYS' => '', 'METHODS' => '', - 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters') - ])->saveTo($savePath, "{$name}.php"); + 'NAMESPACE' => $namespace, + ])->saveTo($savePath, "{$class}.php"); - $this->info("✅ Filter class '{$name}.php' created successfully."); + $this->info("✅ Filter class '{$class}.php' created successfully."); return Command::SUCCESS; } @@ -69,18 +73,71 @@ public function handle() // Create final filter class Stub::create('filter.stub', [ - 'CLASS' => $name, + 'CLASS' => $class, 'METHODS' => implode("\n\n", $methods), 'FILTER_KEYS' => "'" . implode("','", $keys) . "'", - 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters') - ])->saveTo($savePath, "{$name}.php"); + 'NAMESPACE' => $namespace, + ])->saveTo($savePath, "{$class}.php"); - $this->info("✅ Filter '{$name}.php' created successfully with methods: " . implode(', ', $keys)); + $this->info("✅ Filter '{$class}.php' created successfully with methods: " . implode(', ', $keys)); return Command::SUCCESS; } + /** + * Get the filter saving path. + * + * @return string + */ protected function getFilterSavingPath(): string { - return config('filterable.save_filters_at', app_path('Http/Filters')); + $path = trim((string) $this->option('path')); + + if ($path === '') { + return rtrim((string) config('filterable.save_filters_at', app_path('Http/Filters')), '/\\'); + } + + if ($this->isAbsolutePath($path)) { + return rtrim($path, '/\\'); + } + + return rtrim(base_path($path), '/\\'); + } + + /** + * Get the filter namespace. + * + * @return string + */ + protected function getFilterNamespace(): string + { + $namespace = trim((string) $this->option('namespace')); + + if ($namespace === '') { + $namespace = (string) config('filterable.namespace', config('filterable.filter_namespace', 'App\\Http\\Filters')); + } + + return trim(str_replace('/', '\\', $namespace), '\\'); + } + + /** + * Resolve the class name from the given name. + * + * @param string $name + * @return string + */ + protected function resolveClassName(string $name): string + { + return Str::of($name)->replace('/', '\\')->afterLast('\\')->toString(); + } + + /** + * Check if the given path is an absolute path. + * + * @param string $path + * @return bool + */ + protected function isAbsolutePath(string $path): bool + { + return Str::startsWith($path, ['/']) || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1; } } diff --git a/tests/Feature/Commands/MakeFilterCommandTest.php b/tests/Feature/Commands/MakeFilterCommandTest.php index 40e64ae..db454bc 100644 --- a/tests/Feature/Commands/MakeFilterCommandTest.php +++ b/tests/Feature/Commands/MakeFilterCommandTest.php @@ -5,7 +5,6 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; -use Kettasoft\Filterable\Support\Stub; use Kettasoft\Filterable\Tests\TestCase; class MakeFilterCommandTest extends TestCase @@ -20,6 +19,8 @@ public function setUp(): void { parent::setUp(); + config()->set('filterable.namespace', 'App\\Http\\Filters'); + config()->set('filterable.save_filters_at', base_path('tests/tmp/Filters')); config()->set('filterable.generator.stubs', __DIR__ . '/../../../stubs/'); } @@ -30,7 +31,7 @@ public function setUp(): void */ protected function tearDown(): void { - File::deleteDirectory(config('filterable.save_filters_at')); + File::deleteDirectory(base_path('tests/tmp')); parent::tearDown(); } @@ -42,13 +43,15 @@ protected function tearDown(): void public function it_creates_basic_filter_file() { $filename = 'UserFilter'; + $filePath = base_path("tests/tmp/Filters/{$filename}.php"); $result = Artisan::call("filterable:make-filter", [ "name" => $filename ]); $this->assertEquals(Command::SUCCESS, $result); - $this->assertTrue(File::exists(app_path('Http/Filters') . "/$filename.php")); + $this->assertTrue(File::exists($filePath)); + $this->assertStringContainsString('namespace App\\Http\\Filters;', File::get($filePath)); } /** @@ -58,6 +61,7 @@ public function it_creates_basic_filter_file() public function it_creates_filter_with_methods_file() { $filename = 'UserFilter'; + $filePath = base_path("tests/tmp/Filters/{$filename}.php"); $result = Artisan::call("filterable:make-filter", [ "name" => $filename, @@ -65,6 +69,29 @@ public function it_creates_filter_with_methods_file() ]); $this->assertEquals(Command::SUCCESS, $result); - $this->assertTrue(File::exists(app_path('Http/Filters') . "/$filename.php")); + $this->assertTrue(File::exists($filePath)); + $this->assertStringContainsString("public function methods(Payload \$payload)", File::get($filePath)); + } + + /** + * It creates filter file using custom path and namespace options. + * @test + */ + public function it_creates_filter_file_using_custom_path_and_namespace_options() + { + $filename = 'BlogPostFilter'; + $relativePath = 'tests/tmp/Modules/Blog/app/Filters'; + $namespace = 'Modules\\Blog\\App\\Filters'; + $filePath = base_path("{$relativePath}/{$filename}.php"); + + $result = Artisan::call("filterable:make-filter", [ + "name" => $filename, + '--path' => $relativePath, + '--namespace' => $namespace, + ]); + + $this->assertEquals(Command::SUCCESS, $result); + $this->assertTrue(File::exists($filePath)); + $this->assertStringContainsString("namespace {$namespace};", File::get($filePath)); } } From c7bd3a4228663a8db2d1812567460bdf0d8cce71 Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:50:49 +0200 Subject: [PATCH 02/13] fix: update Builder type hint to use Illuminate\Contracts\Database\Eloquent\Builder in filter.stub (#49) --- stubs/filter.stub | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stubs/filter.stub b/stubs/filter.stub index efb3ff3..5c195e9 100644 --- a/stubs/filter.stub +++ b/stubs/filter.stub @@ -2,7 +2,7 @@ namespace $$NAMESPACE$$; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Support\Payload; @@ -20,7 +20,7 @@ class $$CLASS$$ extends Filterable /** * Initial processing of the query builder before applying filters. * - * @param Builder $builder + * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ protected function initially(Builder $builder): Builder @@ -31,7 +31,7 @@ class $$CLASS$$ extends Filterable /** * Finalize the query builder after all filters have been applied. * - * @param Builder $builder + * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ protected function finally(Builder $builder): Builder From f5bc0f111f45a1dc48f8188a15677c9bcd2256e3 Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:55:32 +0200 Subject: [PATCH 03/13] refactor(sanitization): overhaul sanitizer core with built-in defaults, aliases & pipe-chaining (#50) * refactor(sanitization): remove HandlerFactory and streamline sanitizer handling in ArrayHandler and Sanitizer No behaviour change; all existing tests pass * feat: add default sanitizers for various data types including Boolean, Clamp, EscapeHtml, Float, Integer, Lowercase, NullIfEmpty, Slug, StripSpecialChars, StripTags, Trim, and Uppercase * feat(sanitization): enhance sanitizer alias management and add new sanitizers * refactor(tests): replace HandlerFactory with Sanitizer in SanitizeUnitTest * test(sanitization): add comprehensive tests for various sanitizers including Trim, Lowercase, Uppercase, StripTags, EscapeHtml, Integer, Float, Boolean, Slug, NullIfEmpty, Clamp, and StripSpecialChars * refactor(tests): remove TrimSanitizer class and update usage in SanitizeUnitTest * feat(sanitization): enhance handle method to support global and field-specific sanitizers * test(sanitization): add tests for global sanitizers and their application order * docs(sanitization): update sanitization documentation with new structure, examples, and additional sanitizers --- docs/sanitization.md | 353 ++++++++++++-- .../Defaults/BooleanSanitizer.php | 52 ++ src/Sanitization/Defaults/ClampSanitizer.php | 67 +++ .../Defaults/EscapeHtmlSanitizer.php | 47 ++ src/Sanitization/Defaults/FloatSanitizer.php | 45 ++ .../Defaults/IntegerSanitizer.php | 47 ++ .../Defaults/LowercaseSanitizer.php | 33 ++ .../Defaults/NullIfEmptySanitizer.php | 51 ++ src/Sanitization/Defaults/SlugSanitizer.php | 52 ++ .../Defaults/StripSpecialCharsSanitizer.php | 63 +++ .../Defaults/StripTagsSanitizer.php | 44 ++ src/Sanitization/Defaults/TrimSanitizer.php | 44 ++ .../Defaults/UppercaseSanitizer.php | 33 ++ src/Sanitization/HandlerFactory.php | 41 -- src/Sanitization/Handlers/ArrayHandler.php | 4 +- src/Sanitization/Sanitizer.php | 139 +++++- tests/Unit/Sanitization/SanitizeUnitTest.php | 94 +++- .../Sanitization/SanitizerDefaultsTest.php | 444 ++++++++++++++++++ tests/Unit/Sanitization/TrimSanitizer.php | 13 - 19 files changed, 1554 insertions(+), 112 deletions(-) create mode 100644 src/Sanitization/Defaults/BooleanSanitizer.php create mode 100644 src/Sanitization/Defaults/ClampSanitizer.php create mode 100644 src/Sanitization/Defaults/EscapeHtmlSanitizer.php create mode 100644 src/Sanitization/Defaults/FloatSanitizer.php create mode 100644 src/Sanitization/Defaults/IntegerSanitizer.php create mode 100644 src/Sanitization/Defaults/LowercaseSanitizer.php create mode 100644 src/Sanitization/Defaults/NullIfEmptySanitizer.php create mode 100644 src/Sanitization/Defaults/SlugSanitizer.php create mode 100644 src/Sanitization/Defaults/StripSpecialCharsSanitizer.php create mode 100644 src/Sanitization/Defaults/StripTagsSanitizer.php create mode 100644 src/Sanitization/Defaults/TrimSanitizer.php create mode 100644 src/Sanitization/Defaults/UppercaseSanitizer.php delete mode 100644 src/Sanitization/HandlerFactory.php create mode 100644 tests/Unit/Sanitization/SanitizerDefaultsTest.php delete mode 100644 tests/Unit/Sanitization/TrimSanitizer.php diff --git a/docs/sanitization.md b/docs/sanitization.md index 77b93ce..018d2c6 100644 --- a/docs/sanitization.md +++ b/docs/sanitization.md @@ -1,100 +1,369 @@ -# Request Sanitization +--- +title: Request Sanitization +description: Learn how to clean and transform incoming request data using the powerful sanitization features of the Filterable package. +tags: + - sanitization + - cleaning + - transformation + - normalization +--- -Sanitization allows you to clean or transform incomming request data **before** validation or filtering is applied. +Sanitization allows you to clean or transform incoming request data **before** validation or filtering is applied. This feature ensures your filters always work with clean and normalized data. +--- + ## Overview -To enable sanitization in your filter class, -define a `protected $sanitizers` property. -Each entry in this array maps a **request key** to one or more sanitizer classes. +To enable sanitization in your filter class, define a `protected $sanitizers` property. +Each entry maps a **request key** to one or more sanitizers, which can be: + +- **Aliases** (e.g., `'trim'`, `'lowercase'`) +- **Pipe-separated strings** (e.g., `'trim|lowercase|slug'`) +- **Class names** (e.g., `TrimSanitizer::class`) +- **Closures** (e.g., `fn($value) => strtolower($value)`) +- **Arrays** of any of the above +- **Instantiated objects** implementing `Sanitizable` + +--- -## Basic Example +## Quick Start + +### Using Built-in Aliases ```php +use Kettasoft\Filterable\Filterable; + class PostFilter extends Filterable { protected $sanitizers = [ - 'title' => TitleSanitizer::class + 'title' => 'trim|lowercase', + 'price' => 'float', + 'status' => 'boolean', + 'slug' => 'trim|slug' ]; // ... } ``` -In this example, `TitleSanitizer` will be applied to the title field of the request before validation or filtering. +--- + +## Built-in Sanitizers + +The package includes 12 ready-to-use sanitizers accessible via **short aliases**: + +| Alias | Class | Description | +| --------------- | ---------------------------- | -------------------------------------------- | +| `trim` | `TrimSanitizer` | Removes leading/trailing whitespace | +| `lowercase` | `LowercaseSanitizer` | Converts to lowercase (multibyte-safe) | +| `uppercase` | `UppercaseSanitizer` | Converts to uppercase (multibyte-safe) | +| `integer` | `IntegerSanitizer` | Casts value to integer | +| `float` | `FloatSanitizer` | Casts value to float | +| `boolean` | `BooleanSanitizer` | Converts truthy/falsy values to boolean | +| `slug` | `SlugSanitizer` | Converts to URL-friendly slug | +| `strip_tags` | `StripTagsSanitizer` | Removes HTML/PHP tags | +| `strip_chars` | `StripSpecialCharsSanitizer` | Removes special characters | +| `escape_html` | `EscapeHtmlSanitizer` | Escapes HTML special characters | +| `null_if_empty` | `NullIfEmptySanitizer` | Converts empty strings to `null` | +| `clamp` | `ClampSanitizer` | Clamps numeric values between min/max bounds | -## Creating a Sanitizer Class +--- -A sanitizer class must implement a `Sanitizable` interface +## Usage Patterns + +### 1. Using Aliases (Recommended) + +The simplest and most readable approach: ```php -class TitleSanitizer implement Sanitizable -{ - public function sanitize(mixed $value) - { - return is_string($value) ? trim($value) : $value; - } -} +protected $sanitizers = [ + 'email' => 'trim|lowercase', + 'name' => 'trim|uppercase', + 'age' => 'integer', + 'bio' => 'strip_tags|trim' +]; ``` -## Multiple Sanitizers Per field - -You can apply multiple sanitizers to the same field by using array: +### 2. Using Class Names ```php +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\LowercaseSanitizer; + protected $sanitizers = [ - 'title' => [ + 'email' => [ TrimSanitizer::class, - CapitalizeSanitizer::class + LowercaseSanitizer::class ] ]; ``` -Sanitizers are applied **in the order defined**. +### 3. Using Closures + +For inline transformations: + +```php +protected $sanitizers = [ + 'phone' => fn($value) => preg_replace('/[^0-9]/', '', $value), + 'username' => fn($value) => strtolower(trim($value)) +]; +``` + +### 4. Using Instantiated Objects + +Useful when you need to pass constructor parameters: + +```php +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; + +protected $sanitizers = [ + 'per_page' => new ClampSanitizer(min: 1, max: 100), + 'discount' => new ClampSanitizer(min: 0, max: 100) +]; +``` + +### 5. Mixing Approaches + +You can combine different approaches: + +```php +protected $sanitizers = [ + 'title' => 'trim|strip_tags', + 'slug' => [ + 'trim', + 'lowercase', + fn($value) => str_replace(' ', '-', $value) + ], + 'price' => new ClampSanitizer(min: 0) +]; +``` + +--- ## Global Sanitizers -You may apply a sanitizer globally to all request inputs by specifying the class **without a key**: +Apply a sanitizer to **all request inputs** by specifying it without a key (using numeric array indexes): + +```php +protected $sanitizers = [ + 'trim', // Global: applies to all fields + 'name' => 'lowercase', + 'email' => 'lowercase' +]; +``` + +You can also use multiple global sanitizers: + +```php +protected $sanitizers = [ + TrimSanitizer::class, // Global #1 + fn($v) => strtolower($v), // Global #2 + 'slug' => 'slug' // Field-specific +]; +``` + +::: tip Execution Order +Global sanitizers run **before** field-specific sanitizers. This ensures all fields are cleaned globally first, then processed individually. + +**Example:** ```php protected $sanitizers = [ - TrimSanitizer::class, // will apply to all keys + 'trim', // Step 1: trim all fields + 'email' => 'lowercase' // Step 2: lowercase only email ]; + +// Input: ' ADMIN@EXAMPLE.COM ' +// After global (trim): 'ADMIN@EXAMPLE.COM' +// After field-specific (lowercase): 'admin@example.com' ``` -::: tip Note -Global sanitizers will run **before** field-specific sanitizers. ::: +--- + +## Creating Custom Sanitizers + +### Implement the `Sanitizable` Interface + +```php + TitleSanitizer::class + ]; +} +``` + +--- + +## Registering Custom Aliases + +You can register your own aliases globally using `Sanitizer::extend()`: + +```php +use Kettasoft\Filterable\Sanitization\Sanitizer; +use App\Sanitizers\TitleSanitizer; + +// In a service provider's boot() method: +Sanitizer::extend('title', TitleSanitizer::class); +``` + +Now you can use the alias anywhere: + +```php +protected $sanitizers = [ + 'post_title' => 'trim|title', + 'page_title' => 'title' +]; +``` + +--- + ## Execution Lifecycle -1. **Global sanitizers** (apply to all keys) -2. Field-specific sanitizers (per key, in array order) -3. Validation -4. Authorization -5. Filtering +Sanitization happens early in the filter execution pipeline: + +1. **Authorization** +2. **Validation** +3. **Global sanitizers** (applied to all keys) +4. **Field-specific sanitizers** (per key, in order) +5. **Filtering** -## Example Scenario +This ensures all downstream processes work with clean, normalized data. + +--- + +## Advanced Examples + +### Example 1: E-commerce Product Filter ```php +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; + class ProductFilter extends Filterable { protected $sanitizers = [ - TrimSanitizer::class, - 'name' => [ - StripTagsSanitizer::class, - CapitalizeSanitizer::class - ] + 'trim', // global: trim all inputs + + 'name' => 'strip_tags|trim', + 'slug' => 'lowercase|slug', + 'price' => 'float', + 'stock' => 'integer', + 'is_active' => 'boolean', + 'discount' => new ClampSanitizer(min: 0, max: 100), + 'per_page' => new ClampSanitizer(min: 1, max: 100) ]; - protected $rules [ - 'name' => ['required', 'string'] + protected $rules = [ + 'name' => ['sometimes', 'string', 'max:255'], + 'price' => ['sometimes', 'numeric', 'min:0'] ]; - public function name($value) + public function name(Payload $payload) + { + return $this->builder->where('name', 'like', $payload->asLike()); + } + + public function price(Payload $payload) { - return $this->builder->where('name', $value); + return $this->builder->where('price', '<=', $payload->asInt()); } } ``` + +### Example 2: User Search Filter + +```php +class UserFilter extends Filterable +{ + protected $sanitizers = [ + 'trim', // Global: trim all inputs + + 'email' => 'lowercase|null_if_empty', + 'username' => 'lowercase', + 'age' => 'integer', + 'bio' => 'strip_tags|escape_html', + 'role' => fn($value) => in_array($value, ['admin', 'user']) ? $value : 'user' + ]; + + public function email(Payload $payload) + { + return $this->builder->where('email', $payload); + } + + public function username(Payload $payload) + { + return $this->builder->where('username', 'like', $payload->asLike()); + } +} +``` + +--- + +## Tips & Best Practices + +::: tip Always Trim User Input +Add `'trim'` as a global sanitizer to prevent whitespace-related bugs: + +```php +protected $sanitizers = [ + 'trim', + // other sanitizers... +]; +``` + +::: + +::: tip Use Pipe Syntax for Readability +Instead of arrays, use pipe-separated strings for better readability: + +```php +// ✅ Good +'email' => 'trim|lowercase|null_if_empty' + +// ❌ Less readable +'email' => [TrimSanitizer::class, LowercaseSanitizer::class, NullIfEmptySanitizer::class] +``` + +::: + +::: warning Order Matters +Sanitizers execute in the order defined. Make sure the order is logical: + +```php +// ✅ Correct order +'slug' => 'trim|lowercase|slug' + +// ❌ Wrong order (slug should be last) +'slug' => 'slug|trim|lowercase' +``` + +::: diff --git a/src/Sanitization/Defaults/BooleanSanitizer.php b/src/Sanitization/Defaults/BooleanSanitizer.php new file mode 100644 index 0000000..5b621d6 --- /dev/null +++ b/src/Sanitization/Defaults/BooleanSanitizer.php @@ -0,0 +1,52 @@ + 'boolean']; + * // "yes" → true | "off" → false + */ +class BooleanSanitizer implements Sanitizable +{ + /** + * String values treated as true. + * @var array + */ + protected array $truthy = ['true', '1', 'yes', 'on']; + + /** + * String values treated as false. + * @var array + */ + protected array $falsy = ['false', '0', 'no', 'off', '']; + + public function sanitize($value): mixed + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $lower = mb_strtolower(trim($value)); + + if (in_array($lower, $this->truthy, true)) return true; + if (in_array($lower, $this->falsy, true)) return false; + } + + if (is_int($value)) { + return $value !== 0; + } + + return (bool) $value; + } +} diff --git a/src/Sanitization/Defaults/ClampSanitizer.php b/src/Sanitization/Defaults/ClampSanitizer.php new file mode 100644 index 0000000..48b9c83 --- /dev/null +++ b/src/Sanitization/Defaults/ClampSanitizer.php @@ -0,0 +1,67 @@ + ClampSanitizer::class]; + * // new ClampSanitizer(1, 100): 150 → 100 | 0 → 1 + */ +class ClampSanitizer implements Sanitizable +{ + /** + * Minimum allowed value (inclusive). Null = no lower bound. + * @var int|float|null + */ + protected int|float|null $min; + + /** + * Maximum allowed value (inclusive). Null = no upper bound. + * @var int|float|null + */ + protected int|float|null $max; + + public function __construct(int|float|null $min = null, int|float|null $max = null) + { + $this->min = $min; + $this->max = $max; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->clamp($v), $value); + } + + return $this->clamp($value); + } + + protected function clamp(mixed $value): mixed + { + if (! is_numeric($value)) { + return $value; + } + + // Preserve float type when either bound is a float + $value = (is_float($this->min) || is_float($this->max)) + ? (float) $value + : (int) $value; + + if ($this->min !== null) { + $value = max($this->min, $value); + } + + if ($this->max !== null) { + $value = min($this->max, $value); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/EscapeHtmlSanitizer.php b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php new file mode 100644 index 0000000..574b6bc --- /dev/null +++ b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php @@ -0,0 +1,47 @@ + 'escape_html']; + * // "" → "<script>alert(1)</script>" + */ +class EscapeHtmlSanitizer implements Sanitizable +{ + /** + * Character encoding. + * @var string + */ + protected string $encoding; + + public function __construct(string $encoding = 'UTF-8') + { + $this->encoding = $encoding; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) + ? htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding) + : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/FloatSanitizer.php b/src/Sanitization/Defaults/FloatSanitizer.php new file mode 100644 index 0000000..2d90d04 --- /dev/null +++ b/src/Sanitization/Defaults/FloatSanitizer.php @@ -0,0 +1,45 @@ + FloatSanitizer::class]; + * // "19.999abc" → 19.999 | new FloatSanitizer(2) → 20.0 + */ +class FloatSanitizer implements Sanitizable +{ + /** + * Number of decimal places to round to (null = no rounding). + * @var int|null + */ + protected ?int $decimals; + + public function __construct(?int $decimals = null) + { + $this->decimals = $decimals; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->cast($v), $value); + } + + return $this->cast($value); + } + + protected function cast(mixed $value): float + { + $float = (float) filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); + + return $this->decimals !== null ? round($float, $this->decimals) : $float; + } +} diff --git a/src/Sanitization/Defaults/IntegerSanitizer.php b/src/Sanitization/Defaults/IntegerSanitizer.php new file mode 100644 index 0000000..4e17f03 --- /dev/null +++ b/src/Sanitization/Defaults/IntegerSanitizer.php @@ -0,0 +1,47 @@ + 'integer']; + * // "42abc" → 42 | "xyz" → 0 + */ +class IntegerSanitizer implements Sanitizable +{ + /** + * Return null instead of 0 for non-numeric values. + * @var bool + */ + protected bool $nullOnFail; + + public function __construct(bool $nullOnFail = false) + { + $this->nullOnFail = $nullOnFail; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->cast($v), $value); + } + + return $this->cast($value); + } + + protected function cast(mixed $value): ?int + { + if (is_numeric($value)) { + return (int) $value; + } + + return $this->nullOnFail ? null : (int) $value; + } +} diff --git a/src/Sanitization/Defaults/LowercaseSanitizer.php b/src/Sanitization/Defaults/LowercaseSanitizer.php new file mode 100644 index 0000000..a21171d --- /dev/null +++ b/src/Sanitization/Defaults/LowercaseSanitizer.php @@ -0,0 +1,33 @@ + 'lowercase']; + * // "Hello@Example.COM" → "hello@example.com" + */ +class LowercaseSanitizer implements Sanitizable +{ + public function sanitize($value): mixed + { + if (is_string($value)) { + return mb_strtolower($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? mb_strtolower($v) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/NullIfEmptySanitizer.php b/src/Sanitization/Defaults/NullIfEmptySanitizer.php new file mode 100644 index 0000000..c1acdf2 --- /dev/null +++ b/src/Sanitization/Defaults/NullIfEmptySanitizer.php @@ -0,0 +1,51 @@ + 'null_if_empty']; + * // "" → null | "0" → null | "hello" → "hello" + */ +class NullIfEmptySanitizer implements Sanitizable +{ + /** + * String representations that should be treated as empty. + * @var array + */ + protected array $emptyValues; + + public function __construct(array $emptyValues = ['', '0', 'null', 'undefined', 'none']) + { + $this->emptyValues = $emptyValues; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->nullify($v), $value); + } + + return $this->nullify($value); + } + + protected function nullify(mixed $value): mixed + { + if (is_null($value)) { + return null; + } + + if (is_string($value) && in_array(trim($value), $this->emptyValues, true)) { + return null; + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/SlugSanitizer.php b/src/Sanitization/Defaults/SlugSanitizer.php new file mode 100644 index 0000000..3b5d60a --- /dev/null +++ b/src/Sanitization/Defaults/SlugSanitizer.php @@ -0,0 +1,52 @@ + 'slug']; + * // "Hello World!" → "hello-world" + */ +class SlugSanitizer implements Sanitizable +{ + /** + * Word separator character. + * @var string + */ + protected string $separator; + + /** + * Language for ASCII transliteration. + * @var string + */ + protected string $language; + + public function __construct(string $separator = '-', string $language = 'en') + { + $this->separator = $separator; + $this->language = $language; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return Str::slug($value, $this->separator, $this->language); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? Str::slug($v, $this->separator, $this->language) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php b/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php new file mode 100644 index 0000000..c3eee21 --- /dev/null +++ b/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php @@ -0,0 +1,63 @@ + 'strip_chars']; + * // "hello@#$world!" → "helloworld" + * + * // Allow underscores and dashes: + * protected $sanitizers = ['username' => new StripSpecialCharsSanitizer('_-')]; + * // "hello_world-2025!" → "hello_world-2025" + */ +class StripSpecialCharsSanitizer implements Sanitizable +{ + /** + * Extra characters to allow in addition to alphanumerics and spaces. + * @var string + */ + protected string $allowed; + + /** + * Replacement string for each stripped character. + * @var string + */ + protected string $replacement; + + public function __construct(string $allowed = '', string $replacement = '') + { + $this->allowed = $allowed; + $this->replacement = $replacement; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return $this->strip($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? $this->strip($v) : $v, + $value + ); + } + + return $value; + } + + protected function strip(string $value): string + { + $extra = preg_quote($this->allowed, '/'); + + return preg_replace("/[^a-zA-Z0-9\s{$extra}]/u", $this->replacement, $value); + } +} diff --git a/src/Sanitization/Defaults/StripTagsSanitizer.php b/src/Sanitization/Defaults/StripTagsSanitizer.php new file mode 100644 index 0000000..618839c --- /dev/null +++ b/src/Sanitization/Defaults/StripTagsSanitizer.php @@ -0,0 +1,44 @@ + 'strip_tags']; + * // "Hello " → "Hello " + */ +class StripTagsSanitizer implements Sanitizable +{ + /** + * Allowed HTML tags (e.g. ''). + * @var string|null + */ + protected ?string $allowedTags; + + public function __construct(?string $allowedTags = null) + { + $this->allowedTags = $allowedTags; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return strip_tags($value, $this->allowedTags); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? strip_tags($v, $this->allowedTags) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/TrimSanitizer.php b/src/Sanitization/Defaults/TrimSanitizer.php new file mode 100644 index 0000000..a77e347 --- /dev/null +++ b/src/Sanitization/Defaults/TrimSanitizer.php @@ -0,0 +1,44 @@ + 'trim']; + * // " hello " → "hello" + */ +class TrimSanitizer implements Sanitizable +{ + /** + * Characters to trim (default: whitespace). + * @var string + */ + protected string $characters; + + public function __construct(string $characters = " \t\n\r\0\x0B") + { + $this->characters = $characters; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return trim($value, $this->characters); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? trim($v, $this->characters) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/UppercaseSanitizer.php b/src/Sanitization/Defaults/UppercaseSanitizer.php new file mode 100644 index 0000000..32f6d3a --- /dev/null +++ b/src/Sanitization/Defaults/UppercaseSanitizer.php @@ -0,0 +1,33 @@ + 'uppercase']; + * // "active" → "ACTIVE" + */ +class UppercaseSanitizer implements Sanitizable +{ + public function sanitize($value): mixed + { + if (is_string($value)) { + return mb_strtoupper($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? mb_strtoupper($v) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/HandlerFactory.php b/src/Sanitization/HandlerFactory.php deleted file mode 100644 index 644eb89..0000000 --- a/src/Sanitization/HandlerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -handle($value); - } - - /** - * Create SanitizerHandler instance based on sanitizer type. - * @param mixed $sanitizer - * @throws \RuntimeException - * @return SanitizeHandler - */ - protected static function makeHandler($sanitizer): SanitizeHandler - { - $handler = match (true) { - is_string($sanitizer) => new StringHandler($sanitizer), - is_callable($sanitizer) => new ClosureHandler($sanitizer), - is_array($sanitizer) => new ArrayHandler($sanitizer), - is_object($sanitizer) => new ObjectHandler($sanitizer), - default => throw new \RuntimeException("Handler is not processable"), - }; - - return $handler; - } -} diff --git a/src/Sanitization/Handlers/ArrayHandler.php b/src/Sanitization/Handlers/ArrayHandler.php index 68c2f49..7de7fe1 100644 --- a/src/Sanitization/Handlers/ArrayHandler.php +++ b/src/Sanitization/Handlers/ArrayHandler.php @@ -2,8 +2,8 @@ namespace Kettasoft\Filterable\Sanitization\Handlers; +use Kettasoft\Filterable\Sanitization\Sanitizer; use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; -use Kettasoft\Filterable\Sanitization\HandlerFactory; class ArrayHandler implements SanitizeHandler { @@ -26,7 +26,7 @@ public function __construct($sanitizers) public function handle(mixed $value): mixed { foreach ($this->sanitizers as $sanitizer) { - $value = HandlerFactory::handle($value, $sanitizer); + $value = Sanitizer::apply($value, $sanitizer); } return $value; diff --git a/src/Sanitization/Sanitizer.php b/src/Sanitization/Sanitizer.php index 2626140..5e7517d 100644 --- a/src/Sanitization/Sanitizer.php +++ b/src/Sanitization/Sanitizer.php @@ -3,12 +3,49 @@ namespace Kettasoft\Filterable\Sanitization; use Illuminate\Support\Traits\ForwardsCalls; -use Kettasoft\Filterable\Sanitization\HandlerFactory; +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\FloatSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\SlugSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\IntegerSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\BooleanSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\LowercaseSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\UppercaseSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\EscapeHtmlSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\StripTagsSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\NullIfEmptySanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\StripSpecialCharsSanitizer; +use Kettasoft\Filterable\Sanitization\Handlers\ArrayHandler; +use Kettasoft\Filterable\Sanitization\Handlers\ObjectHandler; +use Kettasoft\Filterable\Sanitization\Handlers\StringHandler; +use Kettasoft\Filterable\Sanitization\Handlers\ClosureHandler; +use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; class Sanitizer implements \Countable { use ForwardsCalls; + /** + * Built-in alias → FQCN map. + * Can be extended at runtime via {@see Sanitizer::extend()}. + * + * @var array + */ + protected static array $aliases = [ + 'trim' => TrimSanitizer::class, + 'lowercase' => LowercaseSanitizer::class, + 'uppercase' => UppercaseSanitizer::class, + 'integer' => IntegerSanitizer::class, + 'float' => FloatSanitizer::class, + 'boolean' => BooleanSanitizer::class, + 'slug' => SlugSanitizer::class, + 'strip_tags' => StripTagsSanitizer::class, + 'strip_chars' => StripSpecialCharsSanitizer::class, + 'escape_html' => EscapeHtmlSanitizer::class, + 'null_if_empty' => NullIfEmptySanitizer::class, + 'clamp' => ClampSanitizer::class, + ]; + /** * Registered sanitizers to operate upon. * @var array @@ -25,25 +62,108 @@ public function __construct(array $sanitizers) } /** - * Handle sanitizers. + * Register a custom alias that maps a short name to a Sanitizable class. + * + * @param string $alias Short name, e.g. 'my_trim' + * @param class-string $class FQCN implementing Sanitizable + * @return void + */ + public static function extend(string $alias, string $class): void + { + static::$aliases[$alias] = $class; + } + + /** + * Resolve an alias string to its registered FQCN, or return the input as-is + * if it is already a fully-qualified class name (or unregistered string). + * + * @param string $alias + * @return string FQCN + */ + public static function resolveAlias(string $alias): string + { + return static::$aliases[$alias] ?? $alias; + } + + /** + * Return a copy of the full alias → class map. + * + * @return array + */ + public static function getAliases(): array + { + return static::$aliases; + } + + /** + * Handle sanitizers for a given field value. + * + * Applies sanitizers in two phases: + * 1. Global sanitizers (numeric keys) - applied to all fields + * 2. Field-specific sanitizers (string keys) - applied to matching fields only + * * @param string $field - * @param mixed $value + * @param mixed $value + * @return mixed */ - public function handle(string $field, mixed $value) + public function handle(string $field, mixed $value): mixed { - if (empty($field) || !array_key_exists($field, $this->sanitizers)) { + if (empty($field)) { return $value; } + // Phase 1: Apply global sanitizers (numeric keys) foreach ($this->sanitizers as $key => $resolver) { - if ($key === $field) { - $value = HandlerFactory::handle($value, $resolver); + if (is_int($key)) { + $value = static::apply($value, $resolver); } } + // Phase 2: Apply field-specific sanitizers (string keys) + if (array_key_exists($field, $this->sanitizers)) { + $value = static::apply($value, $this->sanitizers[$field]); + } + return $value; } + /** + * Apply a single sanitizer resolver to a value. + * The resolver can be a string alias, a fully-qualified class name, a closure, + * an array of resolvers, or a Sanitizable object. + * + * @param mixed $value + * @param mixed $sanitizer + * @throws \RuntimeException + * @return mixed + */ + public static function apply(mixed $value, mixed $sanitizer): mixed + { + if (is_string($sanitizer) && str_contains($sanitizer, '|')) { + $sanitizer = explode('|', $sanitizer); + } + + return static::makeHandler($sanitizer)->handle($value); + } + + /** + * Build the appropriate SanitizeHandler for the given sanitizer definition. + * + * @param mixed $sanitizer + * @throws \RuntimeException + * @return SanitizeHandler + */ + protected static function makeHandler(mixed $sanitizer): SanitizeHandler + { + return match (true) { + is_string($sanitizer) => new StringHandler(static::resolveAlias($sanitizer)), + is_callable($sanitizer) => new ClosureHandler($sanitizer), + is_array($sanitizer) => new ArrayHandler($sanitizer), + is_object($sanitizer) => new ObjectHandler($sanitizer), + default => throw new \RuntimeException("Handler is not processable"), + }; + } + /** * Get the number of registered sanitizers. * @return int @@ -63,9 +183,10 @@ public function getSanitizers(): array } /** - * Set sanitizer classes + * Set sanitizer classes. + * * @param array $sanitizers - * @param bool $override Override current sanitizers when (true) + * @param bool $override Override current sanitizers when true * @return static */ public function setSanitizers(array $sanitizers, bool $override = true): static diff --git a/tests/Unit/Sanitization/SanitizeUnitTest.php b/tests/Unit/Sanitization/SanitizeUnitTest.php index 7c858be..31c38e7 100644 --- a/tests/Unit/Sanitization/SanitizeUnitTest.php +++ b/tests/Unit/Sanitization/SanitizeUnitTest.php @@ -2,7 +2,8 @@ namespace Kettasoft\Filterable\Tests\Unit\Sanitization; -use Kettasoft\Filterable\Sanitization\HandlerFactory; +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Sanitizer; use Kettasoft\Filterable\Tests\TestCase; class SanitizeUnitTest extends TestCase @@ -16,7 +17,7 @@ public function it_can_sanitize_value_using_resolver_as_string() $value = ' value'; $resolver = TrimSanitizer::class; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -32,7 +33,7 @@ public function it_can_sanitize_value_using_resolver_as_function() return trim($value); }; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -46,7 +47,7 @@ public function it_can_sanitize_value_using_resolver_as_instance() $value = ' value'; $resolver = new TrimSanitizer; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -64,8 +65,91 @@ public function it_can_sanitize_value_using_resolver_as_array() new TrimSanitizer ]; - $afterSanitize = HandlerFactory::handle($value, $resolvers); + $afterSanitize = Sanitizer::apply($value, $resolvers); $this->assertEquals(strtoupper(trim($value)), $afterSanitize); } + + /** + * It applies global sanitizers to all fields. + * @test + */ + public function it_applies_global_sanitizers_to_all_fields() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global sanitizer (numeric key) + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('email', ' TEST@EXAMPLE.COM '); + + // Should trim first (global), then lowercase (field-specific) + $this->assertEquals('test@example.com', $result); + } + + /** + * It applies multiple global sanitizers in order. + * @test + */ + public function it_applies_multiple_global_sanitizers_in_order() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global #1 + fn($v) => strtoupper($v), // Global #2 + 'name' => fn($v) => str_replace(' ', '_', $v) + ]); + + $result = $sanitizer->handle('name', ' john doe '); + + // Should: trim -> uppercase -> replace spaces + $this->assertEquals('JOHN_DOE', $result); + } + + /** + * It applies global sanitizers even when field has no specific sanitizer. + * @test + */ + public function it_applies_global_sanitizers_even_without_field_specific() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('username', ' admin '); + + // Should apply trim even though 'username' has no specific sanitizer + $this->assertEquals('admin', $result); + } + + /** + * It works with only global sanitizers. + * @test + */ + public function it_works_with_only_global_sanitizers() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, + fn($v) => strtolower($v) + ]); + + $result = $sanitizer->handle('any_field', ' HELLO '); + + $this->assertEquals('hello', $result); + } + + /** + * It returns value unchanged when no sanitizers match. + * @test + */ + public function it_returns_value_unchanged_when_no_sanitizers_match() + { + $sanitizer = new Sanitizer([ + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('name', 'John Doe'); + + $this->assertEquals('John Doe', $result); + } } diff --git a/tests/Unit/Sanitization/SanitizerDefaultsTest.php b/tests/Unit/Sanitization/SanitizerDefaultsTest.php new file mode 100644 index 0000000..8e3290e --- /dev/null +++ b/tests/Unit/Sanitization/SanitizerDefaultsTest.php @@ -0,0 +1,444 @@ +assertSame('hello', $s->sanitize(' hello ')); + } + + public function test_trim_sanitizer_trims_each_element_in_array() + { + $s = new TrimSanitizer; + $this->assertSame(['hello', 'world'], $s->sanitize([' hello', 'world '])); + } + + public function test_trim_sanitizer_passes_non_string_unchanged() + { + $s = new TrimSanitizer; + $this->assertSame(42, $s->sanitize(42)); + $this->assertNull($s->sanitize(null)); + } + + public function test_trim_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply(' hello ', 'trim')); + } + + // ───────────────────────────────────────────── + // LowercaseSanitizer + // ───────────────────────────────────────────── + + public function test_lowercase_sanitizer_converts_string_to_lowercase() + { + $s = new LowercaseSanitizer; + $this->assertSame('hello world', $s->sanitize('Hello World')); + } + + public function test_lowercase_sanitizer_handles_multibyte() + { + $s = new LowercaseSanitizer; + $this->assertSame('héllo', $s->sanitize('HÉLLO')); + } + + public function test_lowercase_sanitizer_processes_array() + { + $s = new LowercaseSanitizer; + $this->assertSame(['foo', 'bar'], $s->sanitize(['FOO', 'BAR'])); + } + + public function test_lowercase_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply('HELLO', 'lowercase')); + } + + // ───────────────────────────────────────────── + // UppercaseSanitizer + // ───────────────────────────────────────────── + + public function test_uppercase_sanitizer_converts_string_to_uppercase() + { + $s = new UppercaseSanitizer; + $this->assertSame('HELLO WORLD', $s->sanitize('hello world')); + } + + public function test_uppercase_sanitizer_processes_array() + { + $s = new UppercaseSanitizer; + $this->assertSame(['FOO', 'BAR'], $s->sanitize(['foo', 'bar'])); + } + + public function test_uppercase_alias_resolves_via_handler_factory() + { + $this->assertSame('HELLO', Sanitizer::apply('hello', 'uppercase')); + } + + // ───────────────────────────────────────────── + // StripTagsSanitizer + // ───────────────────────────────────────────── + + public function test_strip_tags_sanitizer_removes_html_tags() + { + $s = new StripTagsSanitizer; + $this->assertSame('Hello ', $s->sanitize('Hello ')); + } + + public function test_strip_tags_sanitizer_removes_script_tags() + { + $s = new StripTagsSanitizer; + // strip_tags removes the safe')); + } + + public function test_strip_tags_sanitizer_allows_specified_tags() + { + $s = new StripTagsSanitizer(''); + // ')); + } + + public function test_strip_tags_sanitizer_processes_array() + { + $s = new StripTagsSanitizer; + $this->assertSame(['Hello', 'World'], $s->sanitize(['Hello', 'World'])); + } + + public function test_strip_tags_alias_resolves_via_handler_factory() + { + $this->assertSame('Hello', Sanitizer::apply('Hello', 'strip_tags')); + } + + // ───────────────────────────────────────────── + // EscapeHtmlSanitizer + // ───────────────────────────────────────────── + + public function test_escape_html_sanitizer_escapes_special_chars() + { + $s = new EscapeHtmlSanitizer; + $this->assertSame('<b>Hi</b>', $s->sanitize('Hi')); + } + + public function test_escape_html_sanitizer_escapes_quotes() + { + $s = new EscapeHtmlSanitizer; + $this->assertSame('say "hi"', $s->sanitize('say "hi"')); + } + + public function test_escape_html_sanitizer_processes_array() + { + $s = new EscapeHtmlSanitizer; + $result = $s->sanitize(['a', 'b']); + $this->assertSame(['<b>a</b>', '<i>b</i>'], $result); + } + + public function test_escape_html_alias_resolves_via_handler_factory() + { + $this->assertSame('<b>', Sanitizer::apply('', 'escape_html')); + } + + // ───────────────────────────────────────────── + // IntegerSanitizer + // ───────────────────────────────────────────── + + public function test_integer_sanitizer_casts_numeric_string_to_int() + { + $s = new IntegerSanitizer; + $this->assertSame(42, $s->sanitize('42')); + } + + public function test_integer_sanitizer_strips_non_numeric_suffix() + { + $s = new IntegerSanitizer; + $this->assertSame(42, $s->sanitize('42abc')); + } + + public function test_integer_sanitizer_returns_null_on_fail_when_configured() + { + $s = new IntegerSanitizer(nullOnFail: true); + $this->assertNull($s->sanitize('abc')); + } + + public function test_integer_sanitizer_processes_array() + { + $s = new IntegerSanitizer; + $this->assertSame([1, 2, 3], $s->sanitize(['1', '2', '3'])); + } + + public function test_integer_alias_resolves_via_handler_factory() + { + $this->assertSame(7, Sanitizer::apply('7', 'integer')); + } + + // ───────────────────────────────────────────── + // FloatSanitizer + // ───────────────────────────────────────────── + + public function test_float_sanitizer_casts_value_to_float() + { + $s = new FloatSanitizer; + $this->assertSame(3.14, $s->sanitize('3.14')); + } + + public function test_float_sanitizer_rounds_to_given_decimals() + { + $s = new FloatSanitizer(decimals: 2); + $this->assertSame(3.14, $s->sanitize('3.1415')); + } + + public function test_float_sanitizer_processes_array() + { + $s = new FloatSanitizer(decimals: 1); + $this->assertSame([1.1, 2.3], $s->sanitize(['1.14', '2.25'])); + } + + public function test_float_alias_resolves_via_handler_factory() + { + $this->assertSame(1.5, Sanitizer::apply('1.5', 'float')); + } + + // ───────────────────────────────────────────── + // BooleanSanitizer + // ───────────────────────────────────────────── + + public function test_boolean_sanitizer_converts_truthy_strings() + { + $s = new BooleanSanitizer; + foreach (['true', '1', 'yes', 'on', 'TRUE', 'YES'] as $v) { + $this->assertTrue($s->sanitize($v), "Expected true for: $v"); + } + } + + public function test_boolean_sanitizer_converts_falsy_strings() + { + $s = new BooleanSanitizer; + foreach (['false', '0', 'no', 'off', '', 'FALSE'] as $v) { + $this->assertFalse($s->sanitize($v), "Expected false for: $v"); + } + } + + public function test_boolean_sanitizer_passes_native_booleans() + { + $s = new BooleanSanitizer; + $this->assertTrue($s->sanitize(true)); + $this->assertFalse($s->sanitize(false)); + } + + public function test_boolean_sanitizer_handles_integer() + { + $s = new BooleanSanitizer; + $this->assertTrue($s->sanitize(1)); + $this->assertFalse($s->sanitize(0)); + } + + public function test_boolean_alias_resolves_via_handler_factory() + { + $this->assertTrue(Sanitizer::apply('yes', 'boolean')); + $this->assertFalse(Sanitizer::apply('no', 'boolean')); + } + + // ───────────────────────────────────────────── + // SlugSanitizer + // ───────────────────────────────────────────── + + public function test_slug_sanitizer_converts_to_slug() + { + $s = new SlugSanitizer; + $this->assertSame('hello-world', $s->sanitize('Hello World!')); + } + + public function test_slug_sanitizer_uses_custom_separator() + { + $s = new SlugSanitizer(separator: '_'); + $this->assertSame('hello_world', $s->sanitize('Hello World')); + } + + public function test_slug_sanitizer_processes_array() + { + $s = new SlugSanitizer; + $this->assertSame(['foo-bar', 'baz'], $s->sanitize(['Foo Bar', 'Baz'])); + } + + public function test_slug_alias_resolves_via_handler_factory() + { + $this->assertSame('hello-world', Sanitizer::apply('Hello World', 'slug')); + } + + // ───────────────────────────────────────────── + // NullIfEmptySanitizer + // ───────────────────────────────────────────── + + public function test_null_if_empty_sanitizer_returns_null_for_empty_string() + { + $s = new NullIfEmptySanitizer; + $this->assertNull($s->sanitize('')); + } + + public function test_null_if_empty_sanitizer_returns_null_for_default_empty_values() + { + $s = new NullIfEmptySanitizer; + foreach (['0', 'null', 'undefined', 'none'] as $v) { + $this->assertNull($s->sanitize($v), "Expected null for: $v"); + } + } + + public function test_null_if_empty_sanitizer_preserves_non_empty_string() + { + $s = new NullIfEmptySanitizer; + $this->assertSame('hello', $s->sanitize('hello')); + } + + public function test_null_if_empty_sanitizer_returns_null_for_null_input() + { + $s = new NullIfEmptySanitizer; + $this->assertNull($s->sanitize(null)); + } + + public function test_null_if_empty_sanitizer_processes_array() + { + $s = new NullIfEmptySanitizer; + $this->assertSame([null, 'hello', null], $s->sanitize(['', 'hello', 'null'])); + } + + public function test_null_if_empty_alias_resolves_via_handler_factory() + { + $this->assertNull(Sanitizer::apply('', 'null_if_empty')); + } + + // ───────────────────────────────────────────── + // ClampSanitizer + // ───────────────────────────────────────────── + + public function test_clamp_sanitizer_enforces_maximum() + { + $s = new ClampSanitizer(max: 100); + $this->assertSame(100, $s->sanitize(150)); + } + + public function test_clamp_sanitizer_enforces_minimum() + { + $s = new ClampSanitizer(min: 1); + $this->assertSame(1, $s->sanitize(0)); + } + + public function test_clamp_sanitizer_does_not_change_value_within_range() + { + $s = new ClampSanitizer(min: 1, max: 100); + $this->assertSame(50, $s->sanitize(50)); + } + + public function test_clamp_sanitizer_works_with_float_bounds() + { + $s = new ClampSanitizer(min: 1.0, max: 5.0); + $this->assertSame(5.0, $s->sanitize(10.0)); + $this->assertSame(1.0, $s->sanitize(0.5)); + } + + public function test_clamp_sanitizer_passes_non_numeric_unchanged() + { + $s = new ClampSanitizer(min: 1, max: 100); + $this->assertSame('abc', $s->sanitize('abc')); + } + + public function test_clamp_sanitizer_processes_array() + { + $s = new ClampSanitizer(min: 1, max: 10); + $this->assertSame([1, 10, 5], $s->sanitize([0, 20, 5])); + } + + public function test_clamp_alias_resolves_via_handler_factory() + { + // alias uses ClampSanitizer with no bounds — value passes through + $this->assertSame(200, Sanitizer::apply(200, 'clamp')); + } + + // ───────────────────────────────────────────── + // StripSpecialCharsSanitizer + // ───────────────────────────────────────────── + + public function test_strip_special_chars_sanitizer_removes_special_characters() + { + $s = new StripSpecialCharsSanitizer; + $this->assertSame('hello world', $s->sanitize('hello@#$ world!')); + } + + public function test_strip_special_chars_sanitizer_allows_extra_chars() + { + $s = new StripSpecialCharsSanitizer(allowed: '_-'); + $this->assertSame('hello_world-2025', $s->sanitize('hello_world-2025!@#')); + } + + public function test_strip_special_chars_sanitizer_uses_replacement() + { + $s = new StripSpecialCharsSanitizer(replacement: '*'); + $this->assertSame('hello*', $s->sanitize('hello!')); + } + + public function test_strip_special_chars_sanitizer_processes_array() + { + $s = new StripSpecialCharsSanitizer; + $this->assertSame(['hello', 'world'], $s->sanitize(['hello!', 'world@'])); + } + + public function test_strip_chars_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply('hello!', 'strip_chars')); + } + + // ───────────────────────────────────────────── + // Sanitizer::aliases() & extend() + // ───────────────────────────────────────────── + + public function test_handler_factory_returns_all_built_in_aliases() + { + $aliases = Sanitizer::getAliases(); + + $expected = [ + 'trim', + 'lowercase', + 'uppercase', + 'strip_tags', + 'escape_html', + 'integer', + 'float', + 'boolean', + 'slug', + 'null_if_empty', + 'clamp', + 'strip_chars', + ]; + + foreach ($expected as $alias) { + $this->assertArrayHasKey($alias, $aliases, "Missing alias: $alias"); + } + } + + public function test_handler_factory_extend_registers_custom_alias() + { + Sanitizer::extend('my_trim', TrimSanitizer::class); + + $this->assertArrayHasKey('my_trim', Sanitizer::getAliases()); + $this->assertSame('hello', Sanitizer::apply(' hello ', 'my_trim')); + } +} diff --git a/tests/Unit/Sanitization/TrimSanitizer.php b/tests/Unit/Sanitization/TrimSanitizer.php deleted file mode 100644 index 05418c4..0000000 --- a/tests/Unit/Sanitization/TrimSanitizer.php +++ /dev/null @@ -1,13 +0,0 @@ - Date: Sun, 12 Apr 2026 12:00:13 +0200 Subject: [PATCH 04/13] fix: update .editorconfig to set indent_size to 2 for consistency --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 1671c9b..64b6e07 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,7 +5,7 @@ charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space -indent_size = 4 +indent_size = 2 trim_trailing_whitespace = true [*.md] From 38901ed7b08c0c4751696773d3e2b4582fef57eb Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 12 Apr 2026 12:00:30 +0200 Subject: [PATCH 05/13] refactor: remove unused test for accessing raw payload value in InvokableEngineTest --- tests/Unit/Engines/InvokableEngineTest.php | 35 ---------------------- 1 file changed, 35 deletions(-) diff --git a/tests/Unit/Engines/InvokableEngineTest.php b/tests/Unit/Engines/InvokableEngineTest.php index 25b2182..e7c7caf 100644 --- a/tests/Unit/Engines/InvokableEngineTest.php +++ b/tests/Unit/Engines/InvokableEngineTest.php @@ -870,41 +870,6 @@ public function views(Payload $payload) } } - /** - * @test - */ - public function it_can_access_raw_payload_value() - { - Post::truncate(); - - Post::factory()->create(['title' => 'Test']); - - request()->merge([ - 'title' => ' Test ' - ]); - - $filter = new class extends Filterable { - protected $filters = ['title']; - protected $sanitizers = [ - 'title' => 'trim' - ]; - - public function title(Payload $payload) - { - // Value is sanitized - $this->assertEquals('Test', $payload->value); - // Raw value is not sanitized - $this->assertEquals(' Test ', $payload->raw()); - - return $this->builder->where('title', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - } - /** * @test */ From 2c17a4859c0ebf0cd9bf5c38516a03c650648420 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 04:34:26 +0200 Subject: [PATCH 06/13] test: add comprehensive tests for FilterableMethodConflictException - Add test for single method conflict detection - Add test for multiple core methods conflicts - Add test to verify non-conflicting methods work correctly - Add test for exception message formatting - Move conflict check outside attempt() block to ensure exception is thrown - Fix Invokable engine to check for conflicts before executing filter methods Closes: Exception handling for method name conflicts --- src/Engines/Invokable.php | 30 ++--- .../FilterableMethodConflictException.php | 16 +++ tests/Unit/Engines/InvokableEngineTest.php | 114 ++++++++++++++++++ 3 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 src/Exceptions/FilterableMethodConflictException.php diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 10f93cb..bf01a50 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -2,17 +2,17 @@ namespace Kettasoft\Filterable\Engines; -use Illuminate\Support\Str; -use Kettasoft\Filterable\Filterable; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\Payload; +use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; -use Kettasoft\Filterable\Engines\Foundation\Engine; -use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; -use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributePipeline; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeRegistry; +use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; +use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; +use Kettasoft\Filterable\Exceptions\FilterableMethodConflictException; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Support\Payload; class Invokable extends Engine { @@ -43,20 +43,20 @@ public function execute(Builder $builder): Builder $this->context->setAllowedFields($this->context->getFilterAttributes()); foreach ($this->context->getFilterAttributes() as $filter) { - $this->attempt(function () use ($filter) { + $method = $this->getMethodName($filter); + + // Check for method name conflicts with Filterable core methods BEFORE attempt. + if (method_exists(Filterable::class, $method)) { + throw new FilterableMethodConflictException($method); + } + + $this->attempt(function () use ($filter, $method) { $dissector = Dissector::parse($this->context->getRequest()->get($filter), $this->defaultOperator()); $payload = new Payload($filter, $dissector->operator, $this->sanitizeValue($filter, $dissector->value), $dissector->value); $clause = (new ClauseFactory($this))->make($payload); - $method = $this->getMethodName($filter); - - // Check for method name conflicts with Filterable core methods. - if (method_exists(Filterable::class, $method)) { - throw new \RuntimeException(sprintf("Filter method [%s] conflicts with core Filterable method.", [$method])); - } - $this->applyFilterMethod($filter, $method, $payload); $this->commit($method, $clause); diff --git a/src/Exceptions/FilterableMethodConflictException.php b/src/Exceptions/FilterableMethodConflictException.php new file mode 100644 index 0000000..681a152 --- /dev/null +++ b/src/Exceptions/FilterableMethodConflictException.php @@ -0,0 +1,16 @@ +assertEquals('active', $posts->first()->status); $this->assertGreaterThanOrEqual(100, $posts->first()->views); } + + /** + * @test + */ + public function it_throws_exception_when_filter_method_conflicts_with_core_filterable_methods() + { + $this->expectException(\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException::class); + + Post::truncate(); + Post::factory()->create(['status' => 'active']); + + // Using 'apply' as filter name which will map to 'apply' method (conflict) + request()->merge([ + 'apply' => 'test' + ]); + + $filter = new class extends Filterable { + protected $filters = ['apply']; + + // This will cause a conflict because Filterable already has apply() method + }; + + // Should throw FilterableMethodConflictException + Post::filter($filter)->get(); + } + + /** + * @test + */ + public function it_throws_exception_for_multiple_core_method_conflicts() + { + $coreMethodsThatShouldConflict = [ + 'apply', + 'filter', + 'getData', + 'getModel', + 'getBuilder', + 'getEngine' + ]; + + foreach ($coreMethodsThatShouldConflict as $coreMethod) { + Post::truncate(); + Post::factory()->create(['status' => 'active']); + + request()->merge([ + $coreMethod => 'test_value' + ]); + + try { + $filter = new class($coreMethod) extends Filterable { + protected $filters = []; + + public function __construct($method) + { + $this->filters = [$method]; + parent::__construct(); + } + }; + + Post::filter($filter)->get(); + + $this->fail("Expected FilterableMethodConflictException for method: {$coreMethod}"); + } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { + $this->assertStringContainsString($coreMethod, $e->getMessage()); + $this->assertStringContainsString('conflicts with core Filterable method', $e->getMessage()); + } + } + } + + /** + * @test + */ + public function it_allows_filter_methods_that_do_not_conflict() + { + Post::query()->delete(); // Use delete instead of truncate to respect RefreshDatabase + + Post::factory()->create(['status' => 'active', 'title' => 'Active Post']); + Post::factory()->create(['status' => 'pending', 'title' => 'Pending Post']); + + request()->merge([ + 'custom_status' => 'active' + ]); + + // This should NOT throw an exception because 'customStatus' is not a core method + $filter = new class extends Filterable { + protected $filters = ['custom_status']; + + public function customStatus(Payload $payload) + { + $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals('Active Post', $posts->first()->title); + } + + /** + * @test + */ + public function it_properly_formats_exception_message() + { + try { + throw new \Kettasoft\Filterable\Exceptions\FilterableMethodConflictException('testMethod'); + } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { + $this->assertEquals( + 'Filter method [testMethod] conflicts with core Filterable method.', + $e->getMessage() + ); + } + } } From 983c83537c0680f646037b659d95e8962b0d01c9 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:41:10 +0200 Subject: [PATCH 07/13] refactor(attributes): replace query+state deps with Engine. --- .../Attributes/AttributeContext.php | 48 +++---------------- 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/src/Engines/Foundation/Attributes/AttributeContext.php b/src/Engines/Foundation/Attributes/AttributeContext.php index d66ca3b..5edbed1 100644 --- a/src/Engines/Foundation/Attributes/AttributeContext.php +++ b/src/Engines/Foundation/Attributes/AttributeContext.php @@ -2,6 +2,9 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Support\Payload; + /** * The context in which attributes are processed. * @@ -12,48 +15,11 @@ class AttributeContext /** * Create a new attribute context instance. * - * @param mixed $query - * @param mixed $payload - * @param array $state + * @param Engine $engine + * @param Payload $payload */ public function __construct( - public mixed $query = null, - public mixed $payload = null, - public array $state = [] + public Engine $engine, + public Payload $payload ) {} - - /** - * Set a value in the context state. - * - * @param string $key - * @param mixed $value - * @return void - */ - public function set(string $key, mixed $value): void - { - $this->state[$key] = $value; - } - - /** - * Get a value from the context state. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function get(string $key, mixed $default = null): mixed - { - return $this->state[$key] ?? $default; - } - - /** - * Check if a key exists in the context state. - * - * @param string $key - * @return bool - */ - public function has(string $key): bool - { - return array_key_exists($key, $this->state); - } } From 627ff25496134057bf613fb87e2ddae8f8f0ef78 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:41:45 +0200 Subject: [PATCH 08/13] refactor(AttributePipeline): remove unused parameter from constructor --- src/Engines/Foundation/Attributes/AttributePipeline.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Engines/Foundation/Attributes/AttributePipeline.php b/src/Engines/Foundation/Attributes/AttributePipeline.php index 3db1d0a..a91847c 100644 --- a/src/Engines/Foundation/Attributes/AttributePipeline.php +++ b/src/Engines/Foundation/Attributes/AttributePipeline.php @@ -17,7 +17,6 @@ class AttributePipeline /** * Create a new attribute pipeline instance. * - * @param AttributeRegistry $registry * @param AttributeContext $context */ public function __construct(protected AttributeContext $context) From 1835df4c8a22ff590cab11dac74d4ccd30972419 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:42:06 +0200 Subject: [PATCH 09/13] refactor: remove unused AttributeHandler implementations --- .../Contracts/AttributeHandlerInterface.php | 17 ----------- .../Handlers/DefaultValueHandler.php | 28 ------------------ .../Attributes/Handlers/RequiredHandler.php | 29 ------------------- 3 files changed, 74 deletions(-) delete mode 100644 src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php delete mode 100644 src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php delete mode 100644 src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php diff --git a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php b/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php deleted file mode 100644 index b1a8bd7..0000000 --- a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -payload; - - if ($payload && $payload->isEmpty()) { - $context->payload->setValue($attribute->value); - } - } -} diff --git a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php b/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php deleted file mode 100644 index 3a43e52..0000000 --- a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php +++ /dev/null @@ -1,29 +0,0 @@ -payload; - - if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($attribute->message, $context->state['key'])); - } - } -} From f4b2ec8f6e7e782936c6002658a2138697f1b471 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:49:37 +0200 Subject: [PATCH 10/13] refactor(engines): replace query deps with Engine context --- src/Engines/Invokable.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index bf01a50..d2c33b8 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -79,11 +79,7 @@ protected function applyFilterMethod(string $key, string $method, Payload $paylo return; } - $attrContext = new AttributeContext( - $this->builder, - $payload, - state: ['method' => $method, 'key' => $key] - ); + $attrContext = new AttributeContext($this, $payload); $pipeline = new AttributePipeline($attrContext); $process = $pipeline->process($this->context, $method); From 9546854b71315d05211df120b757ba2fe8b0d842 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:49:59 +0200 Subject: [PATCH 11/13] fix(Required): update error message to use payload field instead of context state key --- src/Engines/Foundation/Attributes/Annotations/Required.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Engines/Foundation/Attributes/Annotations/Required.php b/src/Engines/Foundation/Attributes/Annotations/Required.php index cd62f62..2323710 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Required.php +++ b/src/Engines/Foundation/Attributes/Annotations/Required.php @@ -37,7 +37,7 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri $payload = $context->payload; if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($this->message, $context->state['key'])); + throw new StrictnessException(sprintf($this->message, $payload->field)); } } } From 83602523a9f5ef2d53384ced2030f3abe687171e Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:50:12 +0200 Subject: [PATCH 12/13] refactor(Scope): update query retrieval method and remove scope applied flag --- src/Engines/Foundation/Attributes/Annotations/Scope.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Engines/Foundation/Attributes/Annotations/Scope.php b/src/Engines/Foundation/Attributes/Annotations/Scope.php index 3206f05..04db929 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Scope.php +++ b/src/Engines/Foundation/Attributes/Annotations/Scope.php @@ -32,8 +32,8 @@ public static function stage(): int */ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void { - /** @var \Illuminate\Contracts\Eloquent\Builder $query */ - $query = $context->query; + /** @var \Illuminate\Database\Eloquent\Builder $query */ + $query = $context->engine->getContext()->getBuilder(); /** @var \Kettasoft\Filterable\Support\Payload $payload */ $payload = $context->payload; @@ -47,9 +47,5 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri } $query->{$scope}($payload->value); - - // Set a flag in context to indicate the scope was applied, - // allowing the engine to optionally skip the filter method execution. - $context->set('scope_applied', true); } } From 4d3098f6a3495820d4605cd62d012ff0fd7b241a Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 17:50:28 +0200 Subject: [PATCH 13/13] refactor(CastAttributeTest): mock Engine in AttributeContext for improved test isolation --- tests/Feature/Engines/Attributes/CastAttributeTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php index a67fcc9..319fa5a 100644 --- a/tests/Feature/Engines/Attributes/CastAttributeTest.php +++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php @@ -218,6 +218,7 @@ public function test_cast_attribute_handle_method_directly() { $payload = Payload::create('views', '=', '42', '42'); $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + engine: $this->createMock(\Kettasoft\Filterable\Engines\Foundation\Engine::class), payload: $payload ); @@ -234,6 +235,7 @@ public function test_cast_attribute_handle_throws_for_invalid_type() $payload = Payload::create('status', '=', 'active', 'active'); $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + engine: $this->createMock(\Kettasoft\Filterable\Engines\Foundation\Engine::class), payload: $payload );