Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions config/filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'),
Expand Down
13 changes: 12 additions & 1 deletion docs/cli/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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
```

---
Expand All @@ -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.

---

Expand Down
108 changes: 93 additions & 15 deletions src/Commands/MakeFilterCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,62 @@
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';

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;
}

// 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;
}

Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion stubs/filter.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
88 changes: 84 additions & 4 deletions tests/Feature/Commands/MakeFilterCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/');
}

Expand All @@ -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();
}
Expand All @@ -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));
}

/**
Expand All @@ -58,13 +61,90 @@ 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,
'--filters' => 'methods'
]);

$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')));
}
}
Loading