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..f2b5738e 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,8 @@ 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`. +- 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 dd87d13a..608fd442 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,32 @@ 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"; + + 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 - $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 +54,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 +83,82 @@ 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; + } + + /** + * 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 efb3ff33..af77887d 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; diff --git a/tests/Feature/Commands/MakeFilterCommandTest.php b/tests/Feature/Commands/MakeFilterCommandTest.php index 40e64aea..46a648bc 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,82 @@ 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)); + } + + /** + * 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'))); } }