From 78bec07637bcd98f4771dfe99f9e297bf0c54f95 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 1/3] 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 5760c1d0..43c07e63 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 84e11ba6..6551af2b 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 90b07e0d..c1aafdd6 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 dd87d13a..10e7967c 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 40e64aea..db454bcf 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 12fea8680af14239ed3f8709f316af4bc68959be 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 2/3] 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 efb3ff33..5c195e97 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 40f25bfd19b3273e8aa47333af81a599c677e309 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 06:47:12 +0300 Subject: [PATCH 3/3] fix: validate generated filter identifiers --- docs/cli/setup.md | 1 + src/Commands/MakeFilterCommand.php | 21 ++++++++ stubs/filter.stub | 4 +- .../Commands/MakeFilterCommandTest.php | 53 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/cli/setup.md b/docs/cli/setup.md index c1aafdd6..f2b5738e 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -86,6 +86,7 @@ php artisan filterable:make-filter 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`. +- Class names and namespaces must be valid PHP qualified names; invalid values fail without creating a file. --- diff --git a/src/Commands/MakeFilterCommand.php b/src/Commands/MakeFilterCommand.php index 10e7967c..608fd442 100644 --- a/src/Commands/MakeFilterCommand.php +++ b/src/Commands/MakeFilterCommand.php @@ -27,6 +27,16 @@ public function handle() $namespace = $this->getFilterNamespace(); $filePath = $savePath . "/{$class}.php"; + if (!$this->isValidQualifiedName($class)) { + $this->error("The filter class name [{$class}] is not valid."); + return Command::FAILURE; + } + + if (!$this->isValidQualifiedName($namespace)) { + $this->error("The filter namespace [{$namespace}] is not valid."); + return Command::FAILURE; + } + Stub::setBasePath(config('filterable.generator.stubs')); // Ensure directory exists @@ -140,4 +150,15 @@ protected function isAbsolutePath(string $path): bool { return Str::startsWith($path, ['/']) || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1; } + + /** + * Determine whether a class or namespace is a valid PHP qualified name. + */ + protected function isValidQualifiedName(string $name): bool + { + return preg_match( + '/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(?:\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/D', + $name + ) === 1; + } } diff --git a/stubs/filter.stub b/stubs/filter.stub index 5c195e97..af77887d 100644 --- a/stubs/filter.stub +++ b/stubs/filter.stub @@ -20,7 +20,7 @@ class $$CLASS$$ extends Filterable /** * Initial processing of the query builder before applying filters. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param 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 \Illuminate\Database\Eloquent\Builder $builder + * @param Builder $builder * @return Builder */ protected function finally(Builder $builder): Builder diff --git a/tests/Feature/Commands/MakeFilterCommandTest.php b/tests/Feature/Commands/MakeFilterCommandTest.php index db454bcf..46a648bc 100644 --- a/tests/Feature/Commands/MakeFilterCommandTest.php +++ b/tests/Feature/Commands/MakeFilterCommandTest.php @@ -94,4 +94,57 @@ public function it_creates_filter_file_using_custom_path_and_namespace_options() $this->assertTrue(File::exists($filePath)); $this->assertStringContainsString("namespace {$namespace};", File::get($filePath)); } + + /** + * It normalizes forward slashes in a custom namespace. + * @test + */ + public function it_normalizes_custom_namespace_separators() + { + $filePath = base_path('tests/tmp/Modules/Blog/SlashFilter.php'); + + $result = Artisan::call('filterable:make-filter', [ + 'name' => 'SlashFilter', + '--path' => 'tests/tmp/Modules/Blog', + '--namespace' => 'Modules/Blog/Filters', + ]); + + $this->assertEquals(Command::SUCCESS, $result); + $this->assertStringContainsString( + 'namespace Modules\\Blog\\Filters;', + File::get($filePath) + ); + } + + /** + * It rejects a namespace that would generate invalid PHP. + * @test + */ + public function it_rejects_an_invalid_custom_namespace() + { + $result = Artisan::call('filterable:make-filter', [ + 'name' => 'InvalidNamespaceFilter', + '--path' => 'tests/tmp/Invalid', + '--namespace' => 'Modules/Invalid-Namespace/Filters', + ]); + + $this->assertEquals(Command::FAILURE, $result); + $this->assertStringContainsString('is not valid', Artisan::output()); + $this->assertFalse(File::exists(base_path('tests/tmp/Invalid/InvalidNamespaceFilter.php'))); + } + + /** + * It rejects a class name that would generate invalid PHP. + * @test + */ + public function it_rejects_an_invalid_filter_class_name() + { + $result = Artisan::call('filterable:make-filter', [ + 'name' => '123InvalidFilter', + ]); + + $this->assertEquals(Command::FAILURE, $result); + $this->assertStringContainsString('class name [123InvalidFilter] is not valid', Artisan::output()); + $this->assertFalse(File::exists(base_path('tests/tmp/Filters/123InvalidFilter.php'))); + } }