diff --git a/config/filterable.php b/config/filterable.php index 84e11ba..7e7c72b 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -179,8 +179,8 @@ | */ 'direction_map' => [ - 'asc' => 'asc', - 'desc' => 'desc', + 'asc' => 'asc', + 'desc' => 'desc', 'prefix' => '-', // "-field" = desc ], @@ -247,17 +247,17 @@ | */ 'allowed_operators' => [ - 'eq' => '=', - 'neq' => '!=', - 'gt' => '>', - 'lt' => '<', - 'gte' => '>=', - 'lte' => '<=', - 'like' => 'like', - 'nlike' => 'not like', - 'in' => 'in', - 'nin' => 'not in', - 'null' => 'is null', + 'eq' => '=', + 'neq' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like', + 'nlike' => 'not like', + 'in' => 'in', + 'nin' => 'not in', + 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', ], @@ -295,17 +295,17 @@ | */ 'allowed_operators' => [ - 'eq' => '=', - 'neq' => '!=', - 'gt' => '>', - 'lt' => '<', - 'gte' => '>=', - 'lte' => '<=', - 'like' => 'like', - 'nlike' => 'not like', - 'in' => 'in', - 'nin' => 'not in', - 'null' => 'is null', + 'eq' => '=', + 'neq' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like', + 'nlike' => 'not like', + 'in' => 'in', + 'nin' => 'not in', + 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', ], @@ -394,17 +394,17 @@ | */ 'allowed_operators' => [ - 'eq' => '=', - 'neq' => '!=', - 'gt' => '>', - 'lt' => '<', - 'gte' => '>=', - 'lte' => '<=', - 'like' => 'like', - 'nlike' => 'not like', - 'in' => 'in', - 'nin' => 'not in', - 'null' => 'is null', + 'eq' => '=', + 'neq' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like', + 'nlike' => 'not like', + 'in' => 'in', + 'nin' => 'not in', + 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', ], @@ -461,17 +461,17 @@ | */ 'allowed_operators' => [ - 'eq' => '=', - 'neq' => '!=', - 'gt' => '>', - 'lt' => '<', - 'gte' => '>=', - 'lte' => '<=', - 'like' => 'like', - 'nlike' => 'not like', - 'in' => 'in', - 'nin' => 'not in', - 'null' => 'is null', + 'eq' => '=', + 'neq' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like', + 'nlike' => 'not like', + 'in' => 'in', + 'nin' => 'not in', + 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', ], @@ -527,7 +527,7 @@ | */ 'normalize_keys' => false, - ] + ], ], /* @@ -842,7 +842,7 @@ */ 'auto_invalidate' => [ 'enabled' => env('FILTERABLE_AUTO_INVALIDATE', false), - 'models' => [ + 'models' => [ // Define model-to-tags mappings here ], ], @@ -857,7 +857,7 @@ | */ 'tracking' => [ - 'enabled' => env('FILTERABLE_CACHE_TRACKING', false), + 'enabled' => env('FILTERABLE_CACHE_TRACKING', false), 'log_channel' => env('FILTERABLE_CACHE_LOG_CHANNEL', 'daily'), ], ], @@ -900,5 +900,5 @@ | */ 'strict' => env('FILTERABLE_EXCEPTION_STRICT', false), - ] + ], ]; diff --git a/database/migrations/CreateProfilerTable.php b/database/migrations/CreateProfilerTable.php index f0fd593..f48d3d9 100644 --- a/database/migrations/CreateProfilerTable.php +++ b/database/migrations/CreateProfilerTable.php @@ -2,49 +2,49 @@ namespace Kettasoft\Filterable\Database\Migrations; -use Illuminate\Support\Facades\Schema; -use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; class CreateProfilerTable extends Migration { - /** - * Run the migrations. - * - * @return void - */ - public function up() - { - Schema::create('profiler', function (Blueprint $table) { - $table->id(); - $table->text('query'); - $table->integer('execution_time'); - $table->integer('memory_usage'); - $table->string('status')->default('success'); // 'success' or 'error' - $table->text('error_message')->nullable(); // For storing error messages if any - $table->string('connection_name')->nullable(); // For multi-connection support - $table->string('model_class')->nullable(); // For storing the model class if applicable - $table->string('query_type')->nullable(); // For storing the type of query (e.g., 'select', 'insert', 'update', 'delete') - $table->string('user_id')->nullable(); // For storing the user ID if applicable - $table->string('ip_address')->nullable(); // For storing the IP address of the user executing the query - $table->string('session_id')->nullable(); // For storing the session ID if applicable - $table->timestamp('executed_at')->useCurrent(); // Timestamp for when the query was executed - $table->string('environment')->nullable(); // For storing the environment (e.g., 'production', 'development', 'testing') - $table->string('application_version')->nullable(); // For storing the application version if applicable - $table->string('query_hash')->nullable(); // For storing a hash of the query for quick lookups - $table->string('request_method')->nullable(); // For storing the HTTP request method (e.g., 'GET', 'POST') - $table->string('request_uri')->nullable(); // For storing the request URI - $table->timestamps(); - }); - } + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('profiler', function (Blueprint $table) { + $table->id(); + $table->text('query'); + $table->integer('execution_time'); + $table->integer('memory_usage'); + $table->string('status')->default('success'); // 'success' or 'error' + $table->text('error_message')->nullable(); // For storing error messages if any + $table->string('connection_name')->nullable(); // For multi-connection support + $table->string('model_class')->nullable(); // For storing the model class if applicable + $table->string('query_type')->nullable(); // For storing the type of query (e.g., 'select', 'insert', 'update', 'delete') + $table->string('user_id')->nullable(); // For storing the user ID if applicable + $table->string('ip_address')->nullable(); // For storing the IP address of the user executing the query + $table->string('session_id')->nullable(); // For storing the session ID if applicable + $table->timestamp('executed_at')->useCurrent(); // Timestamp for when the query was executed + $table->string('environment')->nullable(); // For storing the environment (e.g., 'production', 'development', 'testing') + $table->string('application_version')->nullable(); // For storing the application version if applicable + $table->string('query_hash')->nullable(); // For storing a hash of the query for quick lookups + $table->string('request_method')->nullable(); // For storing the HTTP request method (e.g., 'GET', 'POST') + $table->string('request_uri')->nullable(); // For storing the request URI + $table->timestamps(); + }); + } - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('profiler'); - } + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('profiler'); + } } diff --git a/src/Commands/Concerns/CommandHelpers.php b/src/Commands/Concerns/CommandHelpers.php index d7509c2..c16dc74 100644 --- a/src/Commands/Concerns/CommandHelpers.php +++ b/src/Commands/Concerns/CommandHelpers.php @@ -12,6 +12,7 @@ trait CommandHelpers * * @param string $highlighter * @param string $color + * * @return string */ protected function highlight(string $highlighter, string $color): string @@ -27,7 +28,9 @@ protected function highlight(string $highlighter, string $color): string protected function getFilters(): array { $filtersPath = app_path('Http/Filters'); - if (!File::isDirectory($filtersPath)) return []; + if (!File::isDirectory($filtersPath)) { + return []; + } $classes = []; foreach (File::allFiles($filtersPath) as $file) { @@ -36,6 +39,7 @@ protected function getFilters(): array $classes[] = $class; } } + return $classes; } @@ -43,23 +47,27 @@ protected function getFilters(): array * Convert a file path to a fully qualified class name. * * @param string $path + * * @return string */ protected function pathToClass($path): string { - $relative = str_replace([app_path() . '/', '.php'], '', $path); - return 'App\\' . str_replace('/', '\\', $relative); + $relative = str_replace([app_path().'/', '.php'], '', $path); + + return 'App\\'.str_replace('/', '\\', $relative); } /** * Get the model associated with the filter. * * @param mixed $filter + * * @return string */ protected function getModel($filter): string { - $model = class_basename($filter->getModel()) ?: "N/A"; + $model = class_basename($filter->getModel()) ?: 'N/A'; + return method_exists($filter, 'getModel') ? $model : '-'; } @@ -67,6 +75,7 @@ protected function getModel($filter): string * Get the engine associated with the filter. * * @param mixed $filter + * * @return string */ protected function getEngine($filter): string @@ -78,6 +87,7 @@ protected function getEngine($filter): string * Get the provided data keys from the filter. * * @param mixed $filter + * * @return array */ protected function getProvidedData($filter): array @@ -91,21 +101,28 @@ protected function getProvidedData($filter): array * Resolve the Filterable class from input. * * @param string $input + * * @return string|null */ protected function resolveFilterClass(string $input): ?string { // If fully qualified, just return - if (class_exists($input)) return $input; + if (class_exists($input)) { + return $input; + } // Try with App\Filters namespace $guessed = "App\\Filters\\{$input}"; - if (class_exists($guessed)) return $guessed; + if (class_exists($guessed)) { + return $guessed; + } // Try with configured namespace $namespace = config('filterable.namespace', 'App\\Filters'); $alt = sprintf('%s\\%s', rtrim($namespace, '\\'), $input); - if (class_exists($alt)) return $alt; + if (class_exists($alt)) { + return $alt; + } return null; } diff --git a/src/Commands/FilterableDiscoverCommand.php b/src/Commands/FilterableDiscoverCommand.php index a33c61b..54cf9a3 100644 --- a/src/Commands/FilterableDiscoverCommand.php +++ b/src/Commands/FilterableDiscoverCommand.php @@ -2,16 +2,16 @@ namespace Kettasoft\Filterable\Commands; -use ReflectionClass; use Illuminate\Console\Command; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Illuminate\Database\Eloquent\Model; +use ReflectionClass; /** * Discover searchable columns and suggest indexes for a model. - * + * * Usage: php artisan filterable:discover Post --suggest-indexes */ class FilterableDiscoverCommand extends Command @@ -44,7 +44,7 @@ public function handle(): int } $this->newLine(); - $this->info("Model: " . get_class($this->model)); + $this->info('Model: '.get_class($this->model)); $this->info("Table: {$this->table}"); // Discover columns @@ -90,6 +90,7 @@ protected function resolveModel(): bool if (!$this->model instanceof Model) { $this->error("Class {$class} is not an Eloquent model"); + return false; } @@ -99,15 +100,18 @@ protected function resolveModel(): bool } $this->table = $this->model->getTable(); + return true; } catch (\Exception $e) { - $this->error("Error instantiating model: " . $e->getMessage()); + $this->error('Error instantiating model: '.$e->getMessage()); + return false; } } } $this->error("Model class not found: {$modelClass}"); + return false; } @@ -126,12 +130,12 @@ protected function discoverColumns(): void $type = $columnInfo['type'] ?? 'unknown'; $columnData = [ - 'name' => $column, - 'type' => $type, + 'name' => $column, + 'type' => $type, 'searchable' => $this->isSearchableColumn($column, $type), 'filterable' => $this->isFilterableColumn($column, $type), - 'sortable' => true, - 'indexed' => $this->isColumnIndexed($column), + 'sortable' => true, + 'indexed' => $this->isColumnIndexed($column), 'suggestion' => $this->getColumnSuggestion($column, $type), ]; @@ -161,15 +165,16 @@ protected function getColumnInfo(string $column, string $connectionName): array } return [ - 'type' => $type, + 'type' => $type, 'nullable' => ($columnDetails['Null'] ?? 'YES') === 'YES', - 'default' => $columnDetails['Default'] ?? null, + 'default' => $columnDetails['Default'] ?? null, ]; } } catch (\Exception $e) { // Fallback to Schema facade try { $type = Schema::connection($connectionName)->getColumnType($this->table, $column); + return ['type' => $type]; } catch (\Exception $e) { // Ignore @@ -277,8 +282,8 @@ protected function analyzeColumnData(string $column, string $type): array try { $stats = [ 'distinct_count' => DB::table($this->table)->distinct()->count($column), - 'null_count' => DB::table($this->table)->whereNull($column)->count(), - 'sample_values' => DB::table($this->table) + 'null_count' => DB::table($this->table)->whereNull($column)->count(), + 'sample_values' => DB::table($this->table) ->select($column) ->distinct() ->limit(5) @@ -360,8 +365,8 @@ protected function discoverRelationships(): void if ($return instanceof \Illuminate\Database\Eloquent\Relations\Relation) { $this->relationships[$name] = [ - 'type' => class_basename($return), - 'related' => get_class($return->getRelated()), + 'type' => class_basename($return), + 'related' => get_class($return->getRelated()), 'searchable' => $this->isRelationSearchable($return), ]; } @@ -382,8 +387,7 @@ protected function isRelationSearchable($relation): bool // Check for name/title columns return collect($columns)->contains( - fn($col) => - in_array($col, ['name', 'title', 'email', 'username']) + fn ($col) => in_array($col, ['name', 'title', 'email', 'username']) ); } catch (\Exception $e) { return false; @@ -396,8 +400,8 @@ protected function displayDiscovery(): void $this->info('πŸ“‹ Discovered Columns:'); $this->newLine(); - $searchable = collect($this->columns)->filter(fn($col) => $col['searchable']); - $filterable = collect($this->columns)->filter(fn($col) => $col['filterable']); + $searchable = collect($this->columns)->filter(fn ($col) => $col['searchable']); + $filterable = collect($this->columns)->filter(fn ($col) => $col['filterable']); if ($searchable->isNotEmpty()) { $this->line('Searchable Columns:'); @@ -464,7 +468,7 @@ protected function displayDataAnalysis(): void $this->line(" Null count: {$stats['null_count']}"); if (isset($stats['avg_length'])) { - $this->line(" Avg length: " . number_format($stats['avg_length'], 2)); + $this->line(' Avg length: '.number_format($stats['avg_length'], 2)); } if (!empty($stats['sample_values'])) { @@ -488,7 +492,7 @@ protected function suggestIndexes(): void $this->suggestedIndexes[] = [ 'column' => $name, - 'type' => $this->suggestIndexType($name, $col), + 'type' => $this->suggestIndexType($name, $col), 'reason' => $reason, ]; } @@ -496,10 +500,11 @@ protected function suggestIndexes(): void if (empty($this->suggestedIndexes)) { $this->info('No index suggestions - all important columns are indexed'); + return; } - $rows = collect($this->suggestedIndexes)->map(fn($idx) => [ + $rows = collect($this->suggestedIndexes)->map(fn ($idx) => [ $idx['column'], $idx['type'], $idx['reason'], @@ -544,7 +549,7 @@ protected function createIndexes(): void return; } - if (!$this->confirm('Create ' . count($this->suggestedIndexes) . ' suggested index(es)?')) { + if (!$this->confirm('Create '.count($this->suggestedIndexes).' suggested index(es)?')) { return; } @@ -567,7 +572,7 @@ protected function createIndexes(): void $this->info("βœ… Created {$index['type']} index on {$index['column']}"); } catch (\Exception $e) { - $this->error("❌ Failed to create index on {$index['column']}: " . $e->getMessage()); + $this->error("❌ Failed to create index on {$index['column']}: ".$e->getMessage()); } } } diff --git a/src/Commands/InspectFilterCommand.php b/src/Commands/InspectFilterCommand.php index 641e8c9..2e8db58 100644 --- a/src/Commands/InspectFilterCommand.php +++ b/src/Commands/InspectFilterCommand.php @@ -3,18 +3,16 @@ namespace Kettasoft\Filterable\Commands; use Illuminate\Console\Command; -use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Commands\Concerns\CommandHelpers; +use Kettasoft\Filterable\Filterable; /** * Command to inspect the configuration and structure of a specific Filterable class. - * + * * This command allows users to provide a Filterable class name or alias and * retrieves detailed information about its configuration, including allowed fields, * operators, and the underlying model. It can also optionally display the SQL * query generated by the filter. - * - * @package Kettasoft\Filterable\Commands */ class InspectFilterCommand extends Command { @@ -46,6 +44,7 @@ public function handle() if (!$filterClass || !class_exists($filterClass) || !is_subclass_of($filterClass, Filterable::class)) { $this->error(sprintf("Filter class [%s] not found or is not a subclass of Kettasoft\Filterable\Filterable.", $filterClass)); + return Command::FAILURE; } diff --git a/src/Commands/ListFiltersCommand.php b/src/Commands/ListFiltersCommand.php index c22ca12..d6f893b 100644 --- a/src/Commands/ListFiltersCommand.php +++ b/src/Commands/ListFiltersCommand.php @@ -7,12 +7,10 @@ /** * Command to list all registered Filterable classes and their configurations. - * + * * This command scans the app/Http/Filters directory for classes that extend * the Filterable base class and displays their associated models, allowed fields, * allowed operators, and engines in a tabular format. - * - * @package Kettasoft\Filterable\Commands */ class ListFiltersCommand extends Command { @@ -43,6 +41,7 @@ public function handle() if (empty($filters)) { $this->warn('No filterable classes found.'); + return; } @@ -52,11 +51,11 @@ public function handle() $instance = new $filterClass(); $rows[] = [ - 'Filter' => class_basename($filterClass), - 'Model' => $this->getModel($instance), - 'Fields' => implode(', ', $instance->getAllowedFields() ?? []) ?: 'N/A', + 'Filter' => class_basename($filterClass), + 'Model' => $this->getModel($instance), + 'Fields' => implode(', ', $instance->getAllowedFields() ?? []) ?: 'N/A', 'Operators' => implode(', ', $instance->getAllowedOperators() ?? []) ?: 'N/A', - 'Engine' => $this->getEngine($instance), + 'Engine' => $this->getEngine($instance), ]; } diff --git a/src/Commands/MakeFilterCommand.php b/src/Commands/MakeFilterCommand.php index dd87d13..1c2c3fd 100644 --- a/src/Commands/MakeFilterCommand.php +++ b/src/Commands/MakeFilterCommand.php @@ -2,85 +2,89 @@ namespace Kettasoft\Filterable\Commands; -use Illuminate\Support\Str; use Illuminate\Console\Command; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; use Kettasoft\Filterable\Support\Stub; class MakeFilterCommand extends Command { - protected $signature = 'filterable:make-filter + protected $signature = 'filterable:make-filter {name : The filter class name} {--filters= : Comma-separated filter methods (e.g. status,title)} {--force : Overwrite existing filter if it exists}'; - protected $description = 'Create a new Eloquent filter class'; + protected $description = 'Create a new Eloquent filter class'; - public function handle() - { - $name = trim($this->argument('name')); - $keys = $this->option('filters'); + public function handle() + { + $name = trim($this->argument('name')); + $keys = $this->option('filters'); - Stub::setBasePath(config('filterable.generator.stubs')); + Stub::setBasePath(config('filterable.generator.stubs')); - // Ensure directory exists - $savePath = $this->getFilterSavingPath(); - if (!File::exists($savePath)) { - File::makeDirectory($savePath, 0755, true); - } + // 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}."); - $this->warn('Use the --force option to overwrite it.'); - return Command::FAILURE; - } + // Prevent overwriting existing files + if (File::exists($savePath."/{$name}.php") && !$this->option('force')) { + $this->error("❌ Filter class '{$name}.php' already exists at {$savePath}."); + $this->warn('Use the --force option to overwrite it.'); - // If no filters provided β†’ create simple class - if (!$keys) { - Stub::create('filter.stub', [ - 'CLASS' => $name, - 'FILTER_KEYS' => '', - 'METHODS' => '', - 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters') - ])->saveTo($savePath, "{$name}.php"); - - $this->info("βœ… Filter class '{$name}.php' created successfully."); - return Command::SUCCESS; - } + return Command::FAILURE; + } + + // If no filters provided β†’ create simple class + if (!$keys) { + Stub::create('filter.stub', [ + 'CLASS' => $name, + 'FILTER_KEYS' => '', + 'METHODS' => '', + 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters'), + ])->saveTo($savePath, "{$name}.php"); + + $this->info("βœ… Filter class '{$name}.php' created successfully."); - // Split filters correctly - $keys = str_contains($keys, ',') - ? array_map('trim', explode(',', Str::camel($keys))) - : [Str::camel($keys)]; - - // Generate methods stubs - $methods = []; - foreach ($keys as $key) { - // Reject invalid names (like containing symbols or starting with number) - if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { - $this->error("⚠️ Invalid method name: '$key'"); - return Command::FAILURE; - } - - $methods[] = Stub::create('method.stub', ['NAME' => $key])->render(); + return Command::SUCCESS; + } + + // Split filters correctly + $keys = str_contains($keys, ',') + ? array_map('trim', explode(',', Str::camel($keys))) + : [Str::camel($keys)]; + + // Generate methods stubs + $methods = []; + foreach ($keys as $key) { + // Reject invalid names (like containing symbols or starting with number) + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { + $this->error("⚠️ Invalid method name: '$key'"); + + return Command::FAILURE; + } + + $methods[] = Stub::create('method.stub', ['NAME' => $key])->render(); + } + + // Create final filter class + Stub::create('filter.stub', [ + 'CLASS' => $name, + 'METHODS' => implode("\n\n", $methods), + 'FILTER_KEYS' => "'".implode("','", $keys)."'", + 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters'), + ])->saveTo($savePath, "{$name}.php"); + + $this->info("βœ… Filter '{$name}.php' created successfully with methods: ".implode(', ', $keys)); + + return Command::SUCCESS; } - // Create final filter class - Stub::create('filter.stub', [ - 'CLASS' => $name, - 'METHODS' => implode("\n\n", $methods), - 'FILTER_KEYS' => "'" . implode("','", $keys) . "'", - 'NAMESPACE' => Config::get('filterable.filter_namespace', 'App\\Http\\Filters') - ])->saveTo($savePath, "{$name}.php"); - - $this->info("βœ… Filter '{$name}.php' created successfully with methods: " . implode(', ', $keys)); - return Command::SUCCESS; - } - - protected function getFilterSavingPath(): string - { - return config('filterable.save_filters_at', app_path('Http/Filters')); - } + protected function getFilterSavingPath(): string + { + return config('filterable.save_filters_at', app_path('Http/Filters')); + } } diff --git a/src/Commands/SetupFilterableCommand.php b/src/Commands/SetupFilterableCommand.php index 032cd6e..bcacd17 100644 --- a/src/Commands/SetupFilterableCommand.php +++ b/src/Commands/SetupFilterableCommand.php @@ -16,7 +16,7 @@ public function handle() // 1. Publish the config $this->callSilent('vendor:publish', [ - '--tag' => 'filterable-config', + '--tag' => 'filterable-config', '--force' => $this->option('force'), ]); diff --git a/src/Commands/TestFilterCommand.php b/src/Commands/TestFilterCommand.php index 1bd962b..4d03110 100644 --- a/src/Commands/TestFilterCommand.php +++ b/src/Commands/TestFilterCommand.php @@ -27,10 +27,11 @@ public function handle() $filterNamespace = Config::get('filterable.filter_namespace', 'App\\Http\\Filters'); $filterClass = Str::contains($filterName, '\\') ? $filterName - : $filterNamespace . '\\' . $filterName; + : $filterNamespace.'\\'.$filterName; if (!class_exists($filterClass)) { $this->error("Filter class [$filterClass] not found."); + return Command::FAILURE; } @@ -38,16 +39,18 @@ public function handle() // Resolve model if (!$modelName) { - $this->warn("⚠️ No model provided. Use --model=User"); + $this->warn('⚠️ No model provided. Use --model=User'); + return Command::FAILURE; } $modelClass = Str::contains($modelName, '\\') ? $modelName - : 'App\\Models\\' . $modelName; + : 'App\\Models\\'.$modelName; if (!class_exists($modelClass)) { $this->error("Model class [$modelClass] not found."); + return Command::FAILURE; } @@ -59,16 +62,19 @@ public function handle() $pairs = explode(',', $dataOption); foreach ($pairs as $pair) { [$key, $value] = array_pad(explode('=', $pair, 2), 2, null); - if ($key) $data[trim($key)] = trim($value); + if ($key) { + $data[trim($key)] = trim($value); + } } } if (empty($data)) { - $this->warn("⚠️ No data provided. Use --data=\"status=active,age=30\""); + $this->warn('⚠️ No data provided. Use --data="status=active,age=30"'); + return Command::FAILURE; } - $this->line("Applied filters:"); + $this->line('Applied filters:'); foreach ($data as $key => $value) { $this->line(" β€’ {$key} = {$value}"); } @@ -81,10 +87,11 @@ public function handle() $filter = $filter->apply(); $this->newLine(); - $this->info("βœ… Query:"); + $this->info('βœ… Query:'); $this->info($explainOption ? $filter->toRawSql() : $filter->toSql()); } catch (\Throwable $e) { - $this->error("❌ Error applying filter: " . $e->getMessage()); + $this->error('❌ Error applying filter: '.$e->getMessage()); + return Command::FAILURE; } diff --git a/src/Contracts/Authorizable.php b/src/Contracts/Authorizable.php index c843623..f84e937 100644 --- a/src/Contracts/Authorizable.php +++ b/src/Contracts/Authorizable.php @@ -4,9 +4,10 @@ interface Authorizable { - /** - * Authorization check before running filter operation. - * @return bool - */ - public function authorize(): bool; + /** + * Authorization check before running filter operation. + * + * @return bool + */ + public function authorize(): bool; } diff --git a/src/Contracts/Commitable.php b/src/Contracts/Commitable.php index 846639d..4fb0900 100644 --- a/src/Contracts/Commitable.php +++ b/src/Contracts/Commitable.php @@ -6,15 +6,15 @@ /** * Contract for commitable filters. - * - * @package Kettasoft\Filterable\Contracts */ interface Commitable { /** * Commit applied clauses. + * * @param string $key * @param Clause $clause + * * @return bool */ public function commit(string $key, Clause $clause): bool; diff --git a/src/Contracts/FilterableContext.php b/src/Contracts/FilterableContext.php index 4010441..e408890 100644 --- a/src/Contracts/FilterableContext.php +++ b/src/Contracts/FilterableContext.php @@ -3,18 +3,19 @@ namespace Kettasoft\Filterable\Contracts; use Kettasoft\Filterable\Engines\Contracts\{ - TreeFilterableContext, - RulesetFilterableContect, - ExpressionEngineContext, - InvokableEngineContext + ExpressionEngineContext, + InvokableEngineContext, + RulesetFilterableContect, + TreeFilterableContext }; use Kettasoft\Filterable\Sanitization\Sanitizer; interface FilterableContext extends TreeFilterableContext, RulesetFilterableContect, ExpressionEngineContext, InvokableEngineContext { - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer; + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer; } diff --git a/src/Contracts/Matchable.php b/src/Contracts/Matchable.php index 398131e..ec62830 100644 --- a/src/Contracts/Matchable.php +++ b/src/Contracts/Matchable.php @@ -8,6 +8,7 @@ interface Matchable * Determine if the object matches the given condition or value. * * @param mixed $value + * * @return bool */ public function is($value): bool; diff --git a/src/Contracts/Validatable.php b/src/Contracts/Validatable.php index 7d1fe7f..fb97ee1 100644 --- a/src/Contracts/Validatable.php +++ b/src/Contracts/Validatable.php @@ -4,17 +4,17 @@ interface Validatable { - /** - * Validate the current request. - * - * @return void - */ - public function validate(); + /** + * Validate the current request. + * + * @return void + */ + public function validate(); - /** - * Get the validation rules. - * - * @return array - */ - public function rules(): array; + /** + * Get the validation rules. + * + * @return array + */ + public function rules(): array; } diff --git a/src/Engines/Contracts/Appliable.php b/src/Engines/Contracts/Appliable.php index 700fd71..cf170bd 100644 --- a/src/Engines/Contracts/Appliable.php +++ b/src/Engines/Contracts/Appliable.php @@ -6,11 +6,12 @@ interface Appliable { - /** - * Apply filters to the query builder. - * - * @param Builder $builder - * @return Builder - */ - public function apply(Builder $builder): Builder; + /** + * Apply filters to the query builder. + * + * @param Builder $builder + * + * @return Builder + */ + public function apply(Builder $builder): Builder; } diff --git a/src/Engines/Contracts/Executable.php b/src/Engines/Contracts/Executable.php index d516370..1f8b485 100644 --- a/src/Engines/Contracts/Executable.php +++ b/src/Engines/Contracts/Executable.php @@ -6,10 +6,12 @@ interface Executable { - /** - * Execute using the given query builder instance. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return \Illuminate\Contracts\Database\Eloquent\Builder - */ - public function execute(Builder $builder); + /** + * Execute using the given query builder instance. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return \Illuminate\Contracts\Database\Eloquent\Builder + */ + public function execute(Builder $builder); } diff --git a/src/Engines/Contracts/ExpressionEngineContext.php b/src/Engines/Contracts/ExpressionEngineContext.php index 142c42b..edb3823 100644 --- a/src/Engines/Contracts/ExpressionEngineContext.php +++ b/src/Engines/Contracts/ExpressionEngineContext.php @@ -6,40 +6,47 @@ interface ExpressionEngineContext { - /** - * Get current data. - * @return array - */ - public function getData(): mixed; - - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer; - - /** - * Get allowed fields to apply filtering. - * @return array - */ - public function getAllowedFields(): array; - - /** - * Check if a given relation is allowed for filtering. - * @param string $relation - * @return bool - */ - public function isRelationAllowed(string $relation, $field): bool; - - /** - * Get columns wrapper. - * @return array - */ - public function getFieldsMap(): array; - - /** - * List of supported SQL operators you want to allow when parsing the expressions. - * @return array - */ - public function getAllowedOperators(): array; + /** + * Get current data. + * + * @return array + */ + public function getData(): mixed; + + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer; + + /** + * Get allowed fields to apply filtering. + * + * @return array + */ + public function getAllowedFields(): array; + + /** + * Check if a given relation is allowed for filtering. + * + * @param string $relation + * + * @return bool + */ + public function isRelationAllowed(string $relation, $field): bool; + + /** + * Get columns wrapper. + * + * @return array + */ + public function getFieldsMap(): array; + + /** + * List of supported SQL operators you want to allow when parsing the expressions. + * + * @return array + */ + public function getAllowedOperators(): array; } diff --git a/src/Engines/Contracts/HasAllowedFieldChecker.php b/src/Engines/Contracts/HasAllowedFieldChecker.php index 0f6d159..11c4993 100644 --- a/src/Engines/Contracts/HasAllowedFieldChecker.php +++ b/src/Engines/Contracts/HasAllowedFieldChecker.php @@ -4,9 +4,10 @@ interface HasAllowedFieldChecker extends Strictable { - /** - * Get all allowed fields. - * @return array - */ - public function getAllowedFields(): array; + /** + * Get all allowed fields. + * + * @return array + */ + public function getAllowedFields(): array; } diff --git a/src/Engines/Contracts/HasFieldMap.php b/src/Engines/Contracts/HasFieldMap.php index 25e9ff8..bf97d4a 100644 --- a/src/Engines/Contracts/HasFieldMap.php +++ b/src/Engines/Contracts/HasFieldMap.php @@ -4,10 +4,10 @@ interface HasFieldMap { - /** - * Return an array of field mapping. - * - * @return array - */ - public function getFieldsMap(): array; + /** + * Return an array of field mapping. + * + * @return array + */ + public function getFieldsMap(): array; } diff --git a/src/Engines/Contracts/HasInteractsWithOperators.php b/src/Engines/Contracts/HasInteractsWithOperators.php index d77b26d..607adf5 100644 --- a/src/Engines/Contracts/HasInteractsWithOperators.php +++ b/src/Engines/Contracts/HasInteractsWithOperators.php @@ -4,21 +4,24 @@ interface HasInteractsWithOperators extends Strictable { - /** - * Get operators from engine config. - * @return array - */ - public function getOperatorsFromConfig(): array; + /** + * Get operators from engine config. + * + * @return array + */ + public function getOperatorsFromConfig(): array; - /** - * Get allowed operators only. - * @return array - */ - public function allowedOperators(): array; + /** + * Get allowed operators only. + * + * @return array + */ + public function allowedOperators(): array; - /** - * Default engine operator. - * @return string - */ - public function defaultOperator(); + /** + * Default engine operator. + * + * @return string + */ + public function defaultOperator(); } diff --git a/src/Engines/Contracts/InvokableEngineContext.php b/src/Engines/Contracts/InvokableEngineContext.php index 499a5cf..f1a3e3e 100644 --- a/src/Engines/Contracts/InvokableEngineContext.php +++ b/src/Engines/Contracts/InvokableEngineContext.php @@ -7,34 +7,38 @@ interface InvokableEngineContext { - /** - * Fetch all relevant filters from the filter API class. - * - * @return array - */ - public function getFilterAttributes(): array; + /** + * Fetch all relevant filters from the filter API class. + * + * @return array + */ + public function getFilterAttributes(): array; - /** - * Get the current request instance. - * @return Request - */ - public function getRequest(): Request; + /** + * Get the current request instance. + * + * @return Request + */ + public function getRequest(): Request; - /** - * Check if current filterable class has ignored empty values. - * @return bool - */ - public function hasIgnoredEmptyValues(): bool; + /** + * Check if current filterable class has ignored empty values. + * + * @return bool + */ + public function hasIgnoredEmptyValues(): bool; - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer; + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer; - /** - * Get mentors. - * @return array - */ - public function getMentors(): array; + /** + * Get mentors. + * + * @return array + */ + public function getMentors(): array; } diff --git a/src/Engines/Contracts/Mappable.php b/src/Engines/Contracts/Mappable.php index f8314de..f256e14 100644 --- a/src/Engines/Contracts/Mappable.php +++ b/src/Engines/Contracts/Mappable.php @@ -4,10 +4,12 @@ interface Mappable { - /** - * Map the given key to the corresponding value. - * @param string|null $key - * @return mixed - */ - public function map(string|null $key = null); + /** + * Map the given key to the corresponding value. + * + * @param string|null $key + * + * @return mixed + */ + public function map(?string $key = null); } diff --git a/src/Engines/Contracts/OperatorDefinitionContract.php b/src/Engines/Contracts/OperatorDefinitionContract.php index 0cc7f48..05790c7 100644 --- a/src/Engines/Contracts/OperatorDefinitionContract.php +++ b/src/Engines/Contracts/OperatorDefinitionContract.php @@ -6,30 +6,37 @@ interface OperatorDefinitionContract { - /** - * OperatorDefinition constructor. - * @param \Kettasoft\Filterable\Foundation\Bags\OperatorBag $bag - */ - public function __construct(OperatorBag $bag, bool $isStrict); + /** + * OperatorDefinition constructor. + * + * @param \Kettasoft\Filterable\Foundation\Bags\OperatorBag $bag + */ + public function __construct(OperatorBag $bag, bool $isStrict); - /** - * Check if the operator is allowed. - * @param string $operator - * @return bool - */ - public function isAllowed(string $operator): bool; + /** + * Check if the operator is allowed. + * + * @param string $operator + * + * @return bool + */ + public function isAllowed(string $operator): bool; - /** - * Resolve operator its SQL equivalent. - * @param string|null $operator - * @throws \Kettasoft\Filterable\Exceptions\InvalidOperatorException - * @return string - */ - public function resolve(string|null $operator = null): string|null; + /** + * Resolve operator its SQL equivalent. + * + * @param string|null $operator + * + * @throws \Kettasoft\Filterable\Exceptions\InvalidOperatorException + * + * @return string + */ + public function resolve(?string $operator = null): ?string; - /** - * Get all defined operators. - * @return array - */ - public function all(): array|string|null; + /** + * Get all defined operators. + * + * @return array + */ + public function all(): array|string|null; } diff --git a/src/Engines/Contracts/RulesetFilterableContect.php b/src/Engines/Contracts/RulesetFilterableContect.php index bb14ce1..d7a85d9 100644 --- a/src/Engines/Contracts/RulesetFilterableContect.php +++ b/src/Engines/Contracts/RulesetFilterableContect.php @@ -6,45 +6,52 @@ interface RulesetFilterableContect { - /** - * Get current data. - * @return array - */ - public function getData(): mixed; - - /** - * Check if current filterable class has ignored empty values. - * @return bool - */ - public function hasIgnoredEmptyValues(): bool; - - /** - * Get columns wrapper. - * @return array - */ - public function getFieldsMap(): array; - - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer; - - /** - * Get allowed fields to apply filtering. - * @return array - */ - public function getAllowedFields(): array; - - /** - * List of supported SQL operators you want to allow when parsing the expressions. - * @return array - */ - public function getAllowedOperators(): array; - - /** - * Check if filter has strict mode. - * @return mixed - */ - public function isStrict(); + /** + * Get current data. + * + * @return array + */ + public function getData(): mixed; + + /** + * Check if current filterable class has ignored empty values. + * + * @return bool + */ + public function hasIgnoredEmptyValues(): bool; + + /** + * Get columns wrapper. + * + * @return array + */ + public function getFieldsMap(): array; + + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer; + + /** + * Get allowed fields to apply filtering. + * + * @return array + */ + public function getAllowedFields(): array; + + /** + * List of supported SQL operators you want to allow when parsing the expressions. + * + * @return array + */ + public function getAllowedOperators(): array; + + /** + * Check if filter has strict mode. + * + * @return mixed + */ + public function isStrict(); } diff --git a/src/Engines/Contracts/Skippable.php b/src/Engines/Contracts/Skippable.php index 688af36..576b778 100644 --- a/src/Engines/Contracts/Skippable.php +++ b/src/Engines/Contracts/Skippable.php @@ -8,9 +8,12 @@ interface Skippable { /** * Skip the current execution with a message and optional clause. + * * @param string $message - * @param mixed $clause + * @param mixed $clause + * * @throws SkipExecution + * * @return never */ public function skip(string $message, mixed $clause = null): never; diff --git a/src/Engines/Contracts/Strictable.php b/src/Engines/Contracts/Strictable.php index 2c62279..abe9c88 100644 --- a/src/Engines/Contracts/Strictable.php +++ b/src/Engines/Contracts/Strictable.php @@ -4,9 +4,10 @@ interface Strictable { - /** - * Check if the strict mode is enable. - * @return bool - */ - public function isStrict(): bool; + /** + * Check if the strict mode is enable. + * + * @return bool + */ + public function isStrict(): bool; } diff --git a/src/Engines/Contracts/TreeFilterableContext.php b/src/Engines/Contracts/TreeFilterableContext.php index 1521efc..58f476a 100644 --- a/src/Engines/Contracts/TreeFilterableContext.php +++ b/src/Engines/Contracts/TreeFilterableContext.php @@ -6,45 +6,52 @@ interface TreeFilterableContext { - /** - * Get current data. - * @return array - */ - public function getData(): mixed; - - /** - * Check if current filterable class has ignored empty values. - * @return bool - */ - public function hasIgnoredEmptyValues(): bool; - - /** - * Get columns wrapper. - * @return array - */ - public function getFieldsMap(): array; - - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer; - - /** - * Get allowed fields to apply filtering. - * @return array - */ - public function getAllowedFields(): array; - - /** - * List of supported SQL operators you want to allow when parsing the expressions. - * @return array - */ - public function getAllowedOperators(): array; - - /** - * Check if filter has strict mode. - * @return mixed - */ - public function isStrict(); + /** + * Get current data. + * + * @return array + */ + public function getData(): mixed; + + /** + * Check if current filterable class has ignored empty values. + * + * @return bool + */ + public function hasIgnoredEmptyValues(): bool; + + /** + * Get columns wrapper. + * + * @return array + */ + public function getFieldsMap(): array; + + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer; + + /** + * Get allowed fields to apply filtering. + * + * @return array + */ + public function getAllowedFields(): array; + + /** + * List of supported SQL operators you want to allow when parsing the expressions. + * + * @return array + */ + public function getAllowedOperators(): array; + + /** + * Check if filter has strict mode. + * + * @return mixed + */ + public function isStrict(); } diff --git a/src/Engines/Exceptions/InvalidDataFormatException.php b/src/Engines/Exceptions/InvalidDataFormatException.php index b6a3a37..98ad963 100644 --- a/src/Engines/Exceptions/InvalidDataFormatException.php +++ b/src/Engines/Exceptions/InvalidDataFormatException.php @@ -6,8 +6,8 @@ class InvalidDataFormatException extends StrictnessException { - public function __construct() - { - parent::__construct("The provided data is either incommpatible or incorrectly formatted."); - } + public function __construct() + { + parent::__construct('The provided data is either incommpatible or incorrectly formatted.'); + } } diff --git a/src/Engines/Exceptions/InvalidOperatorException.php b/src/Engines/Exceptions/InvalidOperatorException.php index 9b5a36b..7d9d648 100644 --- a/src/Engines/Exceptions/InvalidOperatorException.php +++ b/src/Engines/Exceptions/InvalidOperatorException.php @@ -4,12 +4,13 @@ class InvalidOperatorException extends SkipExecution { - /** - * InvalidOperatorException constructor. - * @param string $operator - */ - public function __construct(string $operator) - { - parent::__construct(sprintf("Operator [$operator] is invalid")); - } + /** + * InvalidOperatorException constructor. + * + * @param string $operator + */ + public function __construct(string $operator) + { + parent::__construct(sprintf("Operator [$operator] is invalid")); + } } diff --git a/src/Engines/Exceptions/NotAllowedEmptyValueException.php b/src/Engines/Exceptions/NotAllowedEmptyValueException.php index 709e86d..86b66f0 100644 --- a/src/Engines/Exceptions/NotAllowedEmptyValueException.php +++ b/src/Engines/Exceptions/NotAllowedEmptyValueException.php @@ -6,9 +6,10 @@ class NotAllowedEmptyValueException extends SkipExecution { /** * NotAllowedEmptyValueException constructor. + * * @param mixed $message */ - public function __construct($message = "") + public function __construct($message = '') { parent::__construct($message); } diff --git a/src/Engines/Exceptions/NotAllowedFieldException.php b/src/Engines/Exceptions/NotAllowedFieldException.php index 4325af0..033722e 100644 --- a/src/Engines/Exceptions/NotAllowedFieldException.php +++ b/src/Engines/Exceptions/NotAllowedFieldException.php @@ -4,12 +4,13 @@ class NotAllowedFieldException extends SkipExecution { - /** - * NotAllowedFieldException constructor. - * @param string $field - */ - public function __construct(string $field) - { - parent::__construct(sprintf("Field [$field] is not allowed.")); - } + /** + * NotAllowedFieldException constructor. + * + * @param string $field + */ + public function __construct(string $field) + { + parent::__construct(sprintf("Field [$field] is not allowed.")); + } } diff --git a/src/Engines/Exceptions/SkipExecution.php b/src/Engines/Exceptions/SkipExecution.php index 525d9c4..e05f3dd 100644 --- a/src/Engines/Exceptions/SkipExecution.php +++ b/src/Engines/Exceptions/SkipExecution.php @@ -9,8 +9,9 @@ class SkipExecution extends Exception { /** * SkipExecution constructor. + * * @param string $message - * @param mixed $clause + * @param mixed $clause */ public function __construct(string $message, protected mixed $clause = null) { @@ -19,6 +20,7 @@ public function __construct(string $message, protected mixed $clause = null) /** * Get the associated Clause. + * * @return ?Clause */ public function getClause(): ?Clause @@ -28,6 +30,7 @@ public function getClause(): ?Clause /** * Determine if this exception should be reported. + * * @return bool */ public function shouldReport(): bool diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index 202dd45..ffd4f5a 100644 --- a/src/Engines/Expression.php +++ b/src/Engines/Expression.php @@ -3,81 +3,88 @@ namespace Kettasoft\Filterable\Engines; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\Payload; -use Kettasoft\Filterable\Engines\Foundation\Engine; -use Kettasoft\Filterable\Support\ConditionNormalizer; -use Kettasoft\Filterable\Support\ValidateTableColumns; +use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; use Kettasoft\Filterable\Engines\Foundation\ClauseApplier; use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; -use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; +use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; +use Kettasoft\Filterable\Support\ConditionNormalizer; +use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Support\ValidateTableColumns; class Expression extends Engine { - /** - * Engine name. - * @var string - */ - protected $name = 'expression'; + /** + * Engine name. + * + * @var string + */ + protected $name = 'expression'; + + /** + * Apply filters to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + public function execute(Builder $builder): Builder + { + $filters = $this->context->getData(); - /** - * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - public function execute(Builder $builder): Builder - { - $filters = $this->context->getData(); + foreach ($filters as $field => $condition) { + $this->attempt(function () use ($builder, $field, $condition) { + // Normalize the condition to [ operator => value ]. + $condition = ConditionNormalizer::normalize($condition, $this->defaultOperator()); - foreach ($filters as $field => $condition) { - $this->attempt(function () use ($builder, $field, $condition) { + $dissector = Dissector::parse($condition, $this->defaultOperator()); - // Normalize the condition to [ operator => value ]. - $condition = ConditionNormalizer::normalize($condition, $this->defaultOperator()); + $clause = (new ClauseFactory($this))->make( + new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) + ); - $dissector = Dissector::parse($condition, $this->defaultOperator()); + Applier::apply(new ClauseApplier($clause), $builder); - $clause = (new ClauseFactory($this))->make( - new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) - ); + return $this->commit($field, $clause); + }); + } - Applier::apply(new ClauseApplier($clause), $builder); - return $this->commit($field, $clause); - }); + return $builder; } - return $builder; - } + /** + * Check if the given column is registered in schema. + * + * @param mixed $column + * + * @return bool + */ + protected function validateTableColumns($builder, $column) + { + if (config('filterable.engines.expression.validate_columns', false) && !str_contains($column, '.')) { + return ValidateTableColumns::validate($builder, $column); + } - /** - * Check if the given column is registered in schema. - * @param mixed $column - * @return bool - */ - protected function validateTableColumns($builder, $column) - { - if (config('filterable.engines.expression.validate_columns', false) && ! str_contains($column, '.')) { - return ValidateTableColumns::validate($builder, $column); + return true; } - return true; - } - - /** - * Get engine default operator. - * @return string - */ - public function defaultOperator(): string - { - return config('filterable.engines.expression.default_operator', 'eq'); - } + /** + * Get engine default operator. + * + * @return string + */ + public function defaultOperator(): string + { + return config('filterable.engines.expression.default_operator', 'eq'); + } - /** - * Get engine name. - * @return string - */ - public function getEngineName(): string - { - return $this->name; - } + /** + * Get engine name. + * + * @return string + */ + public function getEngineName(): string + { + return $this->name; + } } diff --git a/src/Engines/Factory/EngineManager.php b/src/Engines/Factory/EngineManager.php index 00b71dc..810d27d 100644 --- a/src/Engines/Factory/EngineManager.php +++ b/src/Engines/Factory/EngineManager.php @@ -2,60 +2,66 @@ namespace Kettasoft\Filterable\Engines\Factory; -use Kettasoft\Filterable\Engines\Tree; -use Kettasoft\Filterable\Engines\Ruleset; use Kettasoft\Filterable\Engines\Expression; -use Kettasoft\Filterable\Engines\Invokable; use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Engines\Invokable; +use Kettasoft\Filterable\Engines\Ruleset; +use Kettasoft\Filterable\Engines\Tree; class EngineManager { - /** - * Available engines. - * @var array - */ - protected static $engines = [ - 'tree' => Tree::class, - 'ruleset' => Ruleset::class, - 'expression' => Expression::class, - 'invokable' => Invokable::class - ]; - - /** - * Generate a new engine instance. - * @param \Kettasoft\Filterable\Engines\Foundation\Engine|string $engine - * @throws \InvalidArgumentException - * @return Engine|object - */ - public static function generate(Engine|string $engine, ...$args) - { - if ($engine instanceof Engine) { - return $engine; - } + /** + * Available engines. + * + * @var array + */ + protected static $engines = [ + 'tree' => Tree::class, + 'ruleset' => Ruleset::class, + 'expression' => Expression::class, + 'invokable' => Invokable::class, + ]; - if (is_a($engine, Engine::class, true)) { - return new $engine(...$args); - } + /** + * Generate a new engine instance. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Engine|string $engine + * + * @throws \InvalidArgumentException + * + * @return Engine|object + */ + public static function generate(Engine|string $engine, ...$args) + { + if ($engine instanceof Engine) { + return $engine; + } + + if (is_a($engine, Engine::class, true)) { + return new $engine(...$args); + } - $engine = self::$engines[$engine] ?? throw new \InvalidArgumentException("Unknown engine [$engine]"); - - return new $engine(...$args); - } - - /** - * Extend the engine manager with a custom engine. - * - * @param string $name The name to register the engine under. - * @param string $engineClass The engine class name. - * @throws \InvalidArgumentException - * @return void - */ - public static function extend(string $name, string $engineClass) - { - if (!is_a($engineClass, Engine::class, true)) { - throw new \InvalidArgumentException("Engine class must implement " . Engine::class); + $engine = self::$engines[$engine] ?? throw new \InvalidArgumentException("Unknown engine [$engine]"); + + return new $engine(...$args); } - self::$engines[$name] = $engineClass; - } + /** + * Extend the engine manager with a custom engine. + * + * @param string $name The name to register the engine under. + * @param string $engineClass The engine class name. + * + * @throws \InvalidArgumentException + * + * @return void + */ + public static function extend(string $name, string $engineClass) + { + if (!is_a($engineClass, Engine::class, true)) { + throw new \InvalidArgumentException('Engine class must implement '.Engine::class); + } + + self::$engines[$name] = $engineClass; + } } diff --git a/src/Engines/Foundation/Appliers/Applier.php b/src/Engines/Foundation/Appliers/Applier.php index 429061f..c4a23a9 100644 --- a/src/Engines/Foundation/Appliers/Applier.php +++ b/src/Engines/Foundation/Appliers/Applier.php @@ -7,8 +7,8 @@ abstract class Applier { - public static function apply(Appliable $appliable, Builder $builder) - { - return $appliable->apply($builder); - } + public static function apply(Appliable $appliable, Builder $builder) + { + return $appliable->apply($builder); + } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Authorize.php b/src/Engines/Foundation/Attributes/Annotations/Authorize.php index 9c9a810..24cd276 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Authorize.php +++ b/src/Engines/Foundation/Attributes/Annotations/Authorize.php @@ -7,38 +7,42 @@ #[Attribute(Attribute::TARGET_METHOD)] class Authorize implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Authorize attribute. - * @param class-string<\Kettasoft\Filterable\Contracts\Authorizable> $authorize The class name of the authorization logic. - */ - public function __construct(public string $authorize) {} - - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value; - } + /** + * Constructor for Authorize attribute. + * + * @param class-string<\Kettasoft\Filterable\Contracts\Authorizable> $authorize The class name of the authorization logic. + */ + public function __construct(public string $authorize) + { + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - if (!is_a($this->authorize, \Kettasoft\Filterable\Contracts\Authorizable::class, true)) { - throw new \InvalidArgumentException("The class '{$this->authorize}' must implement the Authorizable contract."); + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value; } - $authorize = new $this->authorize; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + if (!is_a($this->authorize, \Kettasoft\Filterable\Contracts\Authorizable::class, true)) { + throw new \InvalidArgumentException("The class '{$this->authorize}' must implement the Authorizable contract."); + } + + $authorize = new $this->authorize(); - if (! $authorize->authorize()) { - throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'."); + if (!$authorize->authorize()) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'."); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Between.php b/src/Engines/Foundation/Attributes/Annotations/Between.php index ea1b503..5d87cfc 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Between.php +++ b/src/Engines/Foundation/Attributes/Annotations/Between.php @@ -8,50 +8,52 @@ #[Attribute(Attribute::TARGET_METHOD)] class Between implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Between attribute. - * - * @param float|int $min The minimum allowed value. - * @param float|int $max The maximum allowed value. - */ - public function __construct( - public float|int $min, - public float|int $max, - ) {} - - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; - } - - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; - - if (! is_numeric($payload->value)) { - throw new SkipExecution( - "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}." - ); + /** + * Constructor for Between attribute. + * + * @param float|int $min The minimum allowed value. + * @param float|int $max The maximum allowed value. + */ + public function __construct( + public float|int $min, + public float|int $max, + ) { } - $value = (float) $payload->value; + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } - if ($value < $this->min || $value > $this->max) { - throw new SkipExecution( - "The value '{$value}' is not between {$this->min} and {$this->max}." - ); + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if (!is_numeric($payload->value)) { + throw new SkipExecution( + "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}." + ); + } + + $value = (float) $payload->value; + + if ($value < $this->min || $value > $this->max) { + throw new SkipExecution( + "The value '{$value}' is not between {$this->min} and {$this->max}." + ); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Cast.php b/src/Engines/Foundation/Attributes/Annotations/Cast.php index 5e8c475..087cf3c 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Cast.php +++ b/src/Engines/Foundation/Attributes/Annotations/Cast.php @@ -8,38 +8,43 @@ #[Attribute(Attribute::TARGET_METHOD)] class Cast implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Cast attribute. - * @param string $type The type to which the parameter should be cast. - */ - public function __construct(public string $type) {} + /** + * Constructor for Cast attribute. + * + * @param string $type The type to which the parameter should be cast. + */ + public function __construct(public string $type) + { + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - * @throws StrictnessException if the parameter is missing or empty. - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @throws StrictnessException if the parameter is missing or empty. + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - try { - $payload->cast($this->type); - } catch (\Exception $e) { - throw new StrictnessException($e->getMessage()); + try { + $payload->cast($this->type); + } catch (\Exception $e) { + throw new StrictnessException($e->getMessage()); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php index d8f9c0b..ffb2257 100644 --- a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php +++ b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php @@ -7,35 +7,39 @@ #[Attribute(Attribute::TARGET_METHOD)] class DefaultValue implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for DefaultValue attribute. - * @param mixed $value The default value to be used if none is provided. - */ - public function __construct(public mixed $value) {} + /** + * Constructor for DefaultValue attribute. + * + * @param mixed $value The default value to be used if none is provided. + */ + public function __construct(public mixed $value) + { + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if ($payload->isEmpty() || $payload->isNull()) { - $payload->setValue($this->value); + if ($payload->isEmpty() || $payload->isNull()) { + $payload->setValue($this->value); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Explode.php b/src/Engines/Foundation/Attributes/Annotations/Explode.php index 7f0c590..4a5ceea 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Explode.php +++ b/src/Engines/Foundation/Attributes/Annotations/Explode.php @@ -7,33 +7,37 @@ #[Attribute(Attribute::TARGET_METHOD)] class Explode implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Explode attribute. - * @param string $delimiter The delimiter to use for exploding the parameter value. - */ - public function __construct(public string $delimiter = ',') {} + /** + * Constructor for Explode attribute. + * + * @param string $delimiter The delimiter to use for exploding the parameter value. + */ + public function __construct(public string $delimiter = ',') + { + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - $payload->explode($this->delimiter, true); - } + $payload->explode($this->delimiter, true); + } } diff --git a/src/Engines/Foundation/Attributes/Annotations/In.php b/src/Engines/Foundation/Attributes/Annotations/In.php index baa4071..aa60335 100644 --- a/src/Engines/Foundation/Attributes/Annotations/In.php +++ b/src/Engines/Foundation/Attributes/Annotations/In.php @@ -7,47 +7,49 @@ #[Attribute(Attribute::TARGET_METHOD)] class In implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * The allowed values for the parameter. - * - * @var array - */ - protected array $values; + /** + * The allowed values for the parameter. + * + * @var array + */ + protected array $values; - /** - * Constructor for In attribute. - * @param array $values The allowed values for the parameter. - */ - public function __construct(...$values) - { - $this->values = $values; - } + /** + * Constructor for In attribute. + * + * @param array $values The allowed values for the parameter. + */ + public function __construct(...$values) + { + $this->values = $values; + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if ($payload->notIn($this->values)) { - throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( - "The value '{$payload->value}' is not in the allowed set: " . implode(', ', $this->values) - ); + if ($payload->notIn($this->values)) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( + "The value '{$payload->value}' is not in the allowed set: ".implode(', ', $this->values) + ); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/MapValue.php b/src/Engines/Foundation/Attributes/Annotations/MapValue.php index 6e785f7..97017d1 100644 --- a/src/Engines/Foundation/Attributes/Annotations/MapValue.php +++ b/src/Engines/Foundation/Attributes/Annotations/MapValue.php @@ -7,64 +7,66 @@ #[Attribute(Attribute::TARGET_METHOD)] class MapValue implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * The value mapping. - * - * @var array - */ - protected array $map; + /** + * The value mapping. + * + * @var array + */ + protected array $map; - /** - * Whether to skip the filter if the value is not in the map. - * - * @var bool - */ - protected bool $strict; + /** + * Whether to skip the filter if the value is not in the map. + * + * @var bool + */ + protected bool $strict; - /** - * Constructor for MapValue attribute. - * - * @param array $map The value mapping (e.g., ['active' => 1, 'inactive' => 0]). - * @param bool $strict If true, skip execution when value is not found in map. Defaults to false. - */ - public function __construct(array $map, bool $strict = false) - { - $this->map = $map; - $this->strict = $strict; - } + /** + * Constructor for MapValue attribute. + * + * @param array $map The value mapping (e.g., ['active' => 1, 'inactive' => 0]). + * @param bool $strict If true, skip execution when value is not found in map. Defaults to false. + */ + public function __construct(array $map, bool $strict = false) + { + $this->map = $map; + $this->strict = $strict; + } + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; - } + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + $key = (string) $payload->value; - $key = (string) $payload->value; + if (array_key_exists($key, $this->map)) { + $payload->setValue($this->map[$key]); - if (array_key_exists($key, $this->map)) { - $payload->setValue($this->map[$key]); - return; - } + return; + } - if ($this->strict) { - throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( - "The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map)) - ); + if ($this->strict) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( + "The value '{$key}' is not in the value map: ".implode(', ', array_keys($this->map)) + ); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Regex.php b/src/Engines/Foundation/Attributes/Annotations/Regex.php index e478980..06f1613 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Regex.php +++ b/src/Engines/Foundation/Attributes/Annotations/Regex.php @@ -8,48 +8,50 @@ #[Attribute(Attribute::TARGET_METHOD)] class Regex implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Regex attribute. - * - * @param string $pattern The regex pattern to match against. - * @param string $message Optional custom error message. - */ - public function __construct( - public string $pattern, - public string $message = '', - ) {} + /** + * Constructor for Regex attribute. + * + * @param string $pattern The regex pattern to match against. + * @param string $message Optional custom error message. + */ + public function __construct( + public string $pattern, + public string $message = '', + ) { + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if (! is_string($payload->value)) { - throw new SkipExecution( - $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'." - ); - } + if (!is_string($payload->value)) { + throw new SkipExecution( + $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'." + ); + } - if (! preg_match($this->pattern, $payload->value)) { - throw new SkipExecution( - $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'." - ); + if (!preg_match($this->pattern, $payload->value)) { + throw new SkipExecution( + $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'." + ); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Required.php b/src/Engines/Foundation/Attributes/Annotations/Required.php index cd62f62..02fbbf7 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Required.php +++ b/src/Engines/Foundation/Attributes/Annotations/Required.php @@ -8,36 +8,39 @@ #[Attribute(Attribute::TARGET_METHOD)] class Required implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * The error message template. %s will be replaced with the parameter name. - * @var string - */ - public string $message = "The parameter '%s' is required."; + /** + * The error message template. %s will be replaced with the parameter name. + * + * @var string + */ + public string $message = "The parameter '%s' is required."; - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - * @throws StrictnessException if the parameter is missing or empty. - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @throws StrictnessException if the parameter is missing or empty. + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($this->message, $context->state['key'])); + if ($payload && ($payload->isEmpty() || $payload->isNull())) { + throw new StrictnessException(sprintf($this->message, $context->state['key'])); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Sanitize.php b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php index d0d019e..56acd83 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Sanitize.php +++ b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php @@ -7,66 +7,68 @@ #[Attribute(Attribute::TARGET_METHOD)] class Sanitize implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * The sanitization rules to apply. - * - * @var array - */ - protected array $rules; + /** + * The sanitization rules to apply. + * + * @var array + */ + protected array $rules; - /** - * Constructor for Sanitize attribute. - * - * Supported rules: 'lowercase', 'uppercase', 'ucfirst', 'strip_tags', 'nl2br', 'slug', 'trim'. - * - * @param string ...$rules The sanitization rules to apply in order. - */ - public function __construct(string ...$rules) - { - $this->rules = $rules; - } + /** + * Constructor for Sanitize attribute. + * + * Supported rules: 'lowercase', 'uppercase', 'ucfirst', 'strip_tags', 'nl2br', 'slug', 'trim'. + * + * @param string ...$rules The sanitization rules to apply in order. + */ + public function __construct(string ...$rules) + { + $this->rules = $rules; + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - * @throws \InvalidArgumentException if a rule is not supported. - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @throws \InvalidArgumentException if a rule is not supported. + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if (! is_string($payload->value)) { - return; - } + if (!is_string($payload->value)) { + return; + } - $value = $payload->value; + $value = $payload->value; - foreach ($this->rules as $rule) { - $value = match ($rule) { - 'lowercase' => mb_strtolower($value), - 'uppercase' => mb_strtoupper($value), - 'ucfirst' => ucfirst($value), - 'strip_tags' => strip_tags($value), - 'nl2br' => nl2br($value), - 'slug' => \Illuminate\Support\Str::slug($value), - 'trim' => trim($value), - default => throw new \InvalidArgumentException("Sanitization rule [{$rule}] is not supported."), - }; - } + foreach ($this->rules as $rule) { + $value = match ($rule) { + 'lowercase' => mb_strtolower($value), + 'uppercase' => mb_strtoupper($value), + 'ucfirst' => ucfirst($value), + 'strip_tags' => strip_tags($value), + 'nl2br' => nl2br($value), + 'slug' => \Illuminate\Support\Str::slug($value), + 'trim' => trim($value), + default => throw new \InvalidArgumentException("Sanitization rule [{$rule}] is not supported."), + }; + } - $payload->setValue($value); - } + $payload->setValue($value); + } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Scope.php b/src/Engines/Foundation/Attributes/Annotations/Scope.php index 3206f05..2480050 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Scope.php +++ b/src/Engines/Foundation/Attributes/Annotations/Scope.php @@ -7,49 +7,52 @@ #[Attribute(Attribute::TARGET_METHOD)] class Scope implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Scope attribute. - * - * @param string $scope The name of the Eloquent scope to apply (without the 'scope' prefix). - */ - public function __construct(public string $scope) {} - - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::BEHAVIOR->value; - } - - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Illuminate\Contracts\Eloquent\Builder $query */ - $query = $context->query; - - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; - - $scope = $this->scope; - - if (! method_exists($query->getModel(), 'scope' . ucfirst($scope))) { - throw new \InvalidArgumentException( - "The scope '{$scope}' does not exist on the model '" . get_class($query->getModel()) . "'." - ); + /** + * Constructor for Scope attribute. + * + * @param string $scope The name of the Eloquent scope to apply (without the 'scope' prefix). + */ + public function __construct(public string $scope) + { } - $query->{$scope}($payload->value); + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::BEHAVIOR->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); - } + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Illuminate\Contracts\Eloquent\Builder $query */ + $query = $context->query; + + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + $scope = $this->scope; + + if (!method_exists($query->getModel(), 'scope'.ucfirst($scope))) { + throw new \InvalidArgumentException( + "The scope '{$scope}' does not exist on the model '".get_class($query->getModel())."'." + ); + } + + $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); + } } diff --git a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php index 583fa0d..c2ef9d2 100644 --- a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php +++ b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php @@ -8,63 +8,65 @@ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class SkipIf implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for SkipIf attribute. - * - * @param string $check The Payload is* check name (e.g., 'empty', 'null', 'emptyString', 'boolean'). - * Prefix with '!' to negate (e.g., '!numeric'). - * @param string $message Optional custom message when skipping. - */ - public function __construct( - public string $check, - public string $message = '', - ) {} + /** + * Constructor for SkipIf attribute. + * + * @param string $check The Payload is* check name (e.g., 'empty', 'null', 'emptyString', 'boolean'). + * Prefix with '!' to negate (e.g., '!numeric'). + * @param string $message Optional custom message when skipping. + */ + public function __construct( + public string $check, + public string $message = '', + ) { + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value; - } + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value; + } - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - $check = $this->check; - $negate = false; + $check = $this->check; + $negate = false; - if (str_starts_with($check, '!')) { - $negate = true; - $check = substr($check, 1); - } + if (str_starts_with($check, '!')) { + $negate = true; + $check = substr($check, 1); + } - $method = 'is' . ucfirst($check); + $method = 'is'.ucfirst($check); - if (! method_exists($payload, $method)) { - throw new \InvalidArgumentException("Check method [{$method}] does not exist on Payload."); - } + if (!method_exists($payload, $method)) { + throw new \InvalidArgumentException("Check method [{$method}] does not exist on Payload."); + } - $result = $payload->$method(); + $result = $payload->$method(); - if ($negate) { - $result = ! $result; - } + if ($negate) { + $result = !$result; + } - if ($result) { - throw new SkipExecution( - $this->message ?: "Filter skipped because payload {$this->check} check was true." - ); + if ($result) { + throw new SkipExecution( + $this->message ?: "Filter skipped because payload {$this->check} check was true." + ); + } } - } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Trim.php b/src/Engines/Foundation/Attributes/Annotations/Trim.php index 901e92e..a8cbde9 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Trim.php +++ b/src/Engines/Foundation/Attributes/Annotations/Trim.php @@ -7,45 +7,48 @@ #[Attribute(Attribute::TARGET_METHOD)] class Trim implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - /** - * Constructor for Trim attribute. - * @param string $characters Optional characters to trim. Defaults to standard whitespace. - * @param string $side The side to trim: 'both', 'left', or 'right'. Defaults to 'both'. - */ - public function __construct( - public string $characters = " \t\n\r\0\x0B", - public string $side = 'both' - ) {} + /** + * Constructor for Trim attribute. + * + * @param string $characters Optional characters to trim. Defaults to standard whitespace. + * @param string $side The side to trim: 'both', 'left', or 'right'. Defaults to 'both'. + */ + public function __construct( + public string $characters = " \t\n\r\0\x0B", + public string $side = 'both' + ) { + } + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } - /** - * Get the stage at which this attribute should be applied. - * - * @return int - */ - public static function stage(): int - { - return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; - } + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - /** - * Handle the attribute logic. - * - * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context - * @return void - */ - public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void - { - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + if (!is_string($payload->value)) { + return; + } - if (! is_string($payload->value)) { - return; + $payload->setValue(match ($this->side) { + 'left' => ltrim($payload->value, $this->characters), + 'right' => rtrim($payload->value, $this->characters), + default => trim($payload->value, $this->characters), + }); } - - $payload->setValue(match ($this->side) { - 'left' => ltrim($payload->value, $this->characters), - 'right' => rtrim($payload->value, $this->characters), - default => trim($payload->value, $this->characters), - }); - } } diff --git a/src/Engines/Foundation/Attributes/AttributeContext.php b/src/Engines/Foundation/Attributes/AttributeContext.php index d66ca3b..9557aa8 100644 --- a/src/Engines/Foundation/Attributes/AttributeContext.php +++ b/src/Engines/Foundation/Attributes/AttributeContext.php @@ -4,56 +4,58 @@ /** * The context in which attributes are processed. - * - * @package Kettasoft\Filterable\Engines\Foundation\Attributes */ class AttributeContext { - /** - * Create a new attribute context instance. - * - * @param mixed $query - * @param mixed $payload - * @param array $state - */ - public function __construct( - public mixed $query = null, - public mixed $payload = null, - public array $state = [] - ) {} + /** + * Create a new attribute context instance. + * + * @param mixed $query + * @param mixed $payload + * @param array $state + */ + public function __construct( + public mixed $query = null, + public mixed $payload = null, + public array $state = [] + ) { + } - /** - * 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; - } + /** + * 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; - } + /** + * 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); - } + /** + * 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); + } } diff --git a/src/Engines/Foundation/Attributes/AttributePipeline.php b/src/Engines/Foundation/Attributes/AttributePipeline.php index 3db1d0a..b20f746 100644 --- a/src/Engines/Foundation/Attributes/AttributePipeline.php +++ b/src/Engines/Foundation/Attributes/AttributePipeline.php @@ -8,43 +8,45 @@ class AttributePipeline { - /** - * The attribute registry instance. - * @var AttributeRegistry - */ - protected AttributeRegistry $registry; - - /** - * Create a new attribute pipeline instance. - * - * @param AttributeRegistry $registry - * @param AttributeContext $context - */ - public function __construct(protected AttributeContext $context) - { - $this->registry = new AttributeRegistry(); - } - - /** - * Process the attributes for the given target and method. - * - * @param object|string $target - * @return \Kettasoft\Filterable\Engines\Foundation\Contracts\Outcome - */ - public function process(Filterable $target, string $method): Outcome - { - $execution = new Execution(); - - try { - $attributes = $this->registry->getHandlersForMethod($target, $method); - - foreach ($attributes as $attribute) { - $attribute->handle($this->context); - } - } catch (\Exception $e) { - $execution->fail($e); + /** + * The attribute registry instance. + * + * @var AttributeRegistry + */ + protected AttributeRegistry $registry; + + /** + * Create a new attribute pipeline instance. + * + * @param AttributeRegistry $registry + * @param AttributeContext $context + */ + public function __construct(protected AttributeContext $context) + { + $this->registry = new AttributeRegistry(); } - return $execution; - } + /** + * Process the attributes for the given target and method. + * + * @param object|string $target + * + * @return \Kettasoft\Filterable\Engines\Foundation\Contracts\Outcome + */ + public function process(Filterable $target, string $method): Outcome + { + $execution = new Execution(); + + try { + $attributes = $this->registry->getHandlersForMethod($target, $method); + + foreach ($attributes as $attribute) { + $attribute->handle($this->context); + } + } catch (\Exception $e) { + $execution->fail($e); + } + + return $execution; + } } diff --git a/src/Engines/Foundation/Attributes/AttributeRegistry.php b/src/Engines/Foundation/Attributes/AttributeRegistry.php index 496fc65..691b151 100644 --- a/src/Engines/Foundation/Attributes/AttributeRegistry.php +++ b/src/Engines/Foundation/Attributes/AttributeRegistry.php @@ -8,34 +8,35 @@ class AttributeRegistry { - /** - * Get handlers for the given method of a filterable class. - * - * @param Filterable $filterable - * @param string $method - * @return array - */ - public function getHandlersForMethod(Filterable $filterable, string $method): array - { - $reflection = new ReflectionMethod($filterable, $method); - - $resolved = []; - - foreach ($reflection->getAttributes() as $attribute) { - $instance = $attribute->newInstance(); - - if (! $instance instanceof MethodAttribute) { - continue; - } - - $resolved[] = $instance; + /** + * Get handlers for the given method of a filterable class. + * + * @param Filterable $filterable + * @param string $method + * + * @return array + */ + public function getHandlersForMethod(Filterable $filterable, string $method): array + { + $reflection = new ReflectionMethod($filterable, $method); + + $resolved = []; + + foreach ($reflection->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + + if (!$instance instanceof MethodAttribute) { + continue; + } + + $resolved[] = $instance; + } + + usort( + $resolved, + fn ($a, $b) => $a::stage() <=> $b::stage() + ); + + return $resolved; } - - usort( - $resolved, - fn($a, $b) => $a::stage() <=> $b::stage() - ); - - return $resolved; - } } diff --git a/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php index 8862244..52b2836 100644 --- a/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php +++ b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php @@ -9,15 +9,17 @@ */ interface MethodAttribute { - /** - * Get the stage at which this attribute should be applied. - * @return int The stage value. - */ - public static function stage(): int; + /** + * Get the stage at which this attribute should be applied. + * + * @return int The stage value. + */ + public static function stage(): int; - /** - * Handle attribute behavior. - * @param AttributeContext $context The context of the attribute application. - */ - public function handle(AttributeContext $context): void; + /** + * Handle attribute behavior. + * + * @param AttributeContext $context The context of the attribute application. + */ + public function handle(AttributeContext $context): void; } diff --git a/src/Engines/Foundation/Attributes/Enums/Stage.php b/src/Engines/Foundation/Attributes/Enums/Stage.php index 392c848..00e1073 100644 --- a/src/Engines/Foundation/Attributes/Enums/Stage.php +++ b/src/Engines/Foundation/Attributes/Enums/Stage.php @@ -7,23 +7,23 @@ */ enum Stage: int { - /** - * Stop / allow execution. - */ - case CONTROL = 1; + /** + * Stop / allow execution. + */ + case CONTROL = 1; - /** - * Modify payload. - */ - case TRANSFORM = 2; + /** + * Modify payload. + */ + case TRANSFORM = 2; - /** - * Assert correctness. - */ - case VALIDATE = 3; + /** + * Assert correctness. + */ + case VALIDATE = 3; - /** - * Affect query behavior. - */ - case BEHAVIOR = 4; + /** + * Affect query behavior. + */ + case BEHAVIOR = 4; } diff --git a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php b/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php index b1a8bd7..02b1c0b 100644 --- a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php +++ b/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php @@ -6,12 +6,13 @@ interface AttributeHandlerInterface { - /** - * Handle the attribute logic. - * - * @param AttributeContext $context - * @param object $attribute - * @return void - */ - public function handle(AttributeContext $context, object $attribute): void; + /** + * Handle the attribute logic. + * + * @param AttributeContext $context + * @param object $attribute + * + * @return void + */ + public function handle(AttributeContext $context, object $attribute): void; } diff --git a/src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php b/src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php index 250ccb2..802ffb8 100644 --- a/src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php +++ b/src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php @@ -2,27 +2,27 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers; -use Kettasoft\Filterable\Support\Payload; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue; +use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\Contracts\AttributeHandlerInterface; +use Kettasoft\Filterable\Support\Payload; class DefaultValueHandler implements AttributeHandlerInterface { - /** - * @inheritDoc - */ - public function handle(AttributeContext $context, $attribute): void - { - if (! $attribute instanceof DefaultValue) { - return; - } + /** + * @inheritDoc + */ + public function handle(AttributeContext $context, $attribute): void + { + if (!$attribute instanceof DefaultValue) { + return; + } - /** @var Payload $payload */ - $payload = $context->payload; + /** @var Payload $payload */ + $payload = $context->payload; - if ($payload && $payload->isEmpty()) { - $context->payload->setValue($attribute->value); + 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 index 3a43e52..65d4569 100644 --- a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php +++ b/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php @@ -2,28 +2,27 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers; -use Illuminate\Http\Exceptions\HttpResponseException; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required; +use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\Contracts\AttributeHandlerInterface; use Kettasoft\Filterable\Exceptions\StrictnessException; class RequiredHandler implements AttributeHandlerInterface { - /** - * @inheritDoc - */ - public function handle(AttributeContext $context, object $attribute): void - { - if (! $attribute instanceof Required) { - return; - } + /** + * @inheritDoc + */ + public function handle(AttributeContext $context, object $attribute): void + { + if (!$attribute instanceof Required) { + return; + } - /** @var \Kettasoft\Filterable\Support\Payload $payload */ - $payload = $context->payload; + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; - if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($attribute->message, $context->state['key'])); + if ($payload && ($payload->isEmpty() || $payload->isNull())) { + throw new StrictnessException(sprintf($attribute->message, $context->state['key'])); + } } - } } diff --git a/src/Engines/Foundation/Clause.php b/src/Engines/Foundation/Clause.php index f7e1a5f..981dcab 100644 --- a/src/Engines/Foundation/Clause.php +++ b/src/Engines/Foundation/Clause.php @@ -2,131 +2,136 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Support\Collection; use Illuminate\Contracts\Database\Eloquent\Builder; -use Illuminate\Contracts\Support\Jsonable; use Illuminate\Contracts\Support\Arrayable; -use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Support\Collection; use Kettasoft\Filterable\Engines\Foundation\Resolvers\RelationResolver; use Kettasoft\Filterable\Support\Payload; class Clause implements Arrayable, Jsonable { - - /** - * Original field. - * @var string - */ - public readonly string $field; - - /** - * Original operator. - * @var string - */ - public readonly string|null $operator; - - /** - * Original value. - * @var string - */ - public readonly mixed $value; - - /** - * Clause constructor. - * - * @param Payload $payload - */ - public function __construct(Payload $payload) - { - $this->field = $payload->field; - $this->operator = $payload->operator; - $this->value = $payload->value; - } - - /** - * Create Clause instance. - * - * @param Payload $payload - * @return Clause - */ - public static function make(Payload $payload) - { - return new self($payload); - } - - /** - * @inheritDoc - */ - public function isRelational(): bool - { - return is_string($this->field) && str_contains($this->field, '.'); - } - - /** - * @inheritDoc - */ - public function getOriginalField() - { - return $this->field; - } - - /** - * @inheritDoc - */ - public function getValue() - { - return $this->value; - } - - public function relation($bag) - { - $instance = new RelationResolver($bag, $this->field); - return $instance; - } - - /** - * Get the Payload instance. - * - * @return Payload - */ - public function getPayload(): Payload - { - return $this->payload; - } - - public function apply(Builder $builder) - { - return $builder->where( - $this->field, - $this->operator, - $this->value - ); - } - - /** - * @inheritDoc - */ - public function toArray(): array - { - return [ - 'field' => $this->field, - 'operator' => $this->operator, - 'value' => $this->value - ]; - } - - /** - * Convert the object to its JSON representation. - * @param mixed $options - * @return bool|string - */ - public function toJson($options = 0) - { - return json_encode($this->toArray(), $options); - } - - public function toCollection(): Collection - { - return new Collection($this->toArray()); - } + /** + * Original field. + * + * @var string + */ + public readonly string $field; + + /** + * Original operator. + * + * @var string + */ + public readonly ?string $operator; + + /** + * Original value. + * + * @var string + */ + public readonly mixed $value; + + /** + * Clause constructor. + * + * @param Payload $payload + */ + public function __construct(Payload $payload) + { + $this->field = $payload->field; + $this->operator = $payload->operator; + $this->value = $payload->value; + } + + /** + * Create Clause instance. + * + * @param Payload $payload + * + * @return Clause + */ + public static function make(Payload $payload) + { + return new self($payload); + } + + /** + * @inheritDoc + */ + public function isRelational(): bool + { + return is_string($this->field) && str_contains($this->field, '.'); + } + + /** + * @inheritDoc + */ + public function getOriginalField() + { + return $this->field; + } + + /** + * @inheritDoc + */ + public function getValue() + { + return $this->value; + } + + public function relation($bag) + { + $instance = new RelationResolver($bag, $this->field); + + return $instance; + } + + /** + * Get the Payload instance. + * + * @return Payload + */ + public function getPayload(): Payload + { + return $this->payload; + } + + public function apply(Builder $builder) + { + return $builder->where( + $this->field, + $this->operator, + $this->value + ); + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'field' => $this->field, + 'operator' => $this->operator, + 'value' => $this->value, + ]; + } + + /** + * Convert the object to its JSON representation. + * + * @param mixed $options + * + * @return bool|string + */ + public function toJson($options = 0) + { + return json_encode($this->toArray(), $options); + } + + public function toCollection(): Collection + { + return new Collection($this->toArray()); + } } diff --git a/src/Engines/Foundation/ClauseApplier.php b/src/Engines/Foundation/ClauseApplier.php index 9525114..bf22850 100644 --- a/src/Engines/Foundation/ClauseApplier.php +++ b/src/Engines/Foundation/ClauseApplier.php @@ -7,51 +7,60 @@ class ClauseApplier implements Appliable { - /** - * ClauseApplier constructot - * @param \Kettasoft\Filterable\Engines\Foundation\Clause $clause - */ - public function __construct(protected Clause $clause) {} - - /** - * Apply a Clause to the query builder. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return \Illuminate\Contracts\Database\Eloquent\Builder - */ - public function apply(Builder $builder): Builder - { - // if ($this->clause->isRelational()) { - // return $builder; - // } - - if ($this->clause->isRelational()) { - return $this->applyRelational($builder); + /** + * ClauseApplier constructot. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Clause $clause + */ + public function __construct(protected Clause $clause) + { } - return $this->applyDirect($builder); - } - - /** - * Apply a direct (non-relational) clause to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - protected function applyDirect(Builder $builder) - { - return $builder->where($this->clause->field, $this->clause->operator, $this->clause->value); - } - - /** - * Apply a relational clause to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - protected function applyRelational(Builder $builder) - { - [$relation, $field] = explode('.', $this->clause->field, 2); - - return $builder->whereHas($relation, function ($query) use ($field) { - $query->where($field, $this->clause->operator, $this->clause->value); - }); - } + /** + * Apply a Clause to the query builder. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return \Illuminate\Contracts\Database\Eloquent\Builder + */ + public function apply(Builder $builder): Builder + { + // if ($this->clause->isRelational()) { + // return $builder; + // } + + if ($this->clause->isRelational()) { + return $this->applyRelational($builder); + } + + return $this->applyDirect($builder); + } + + /** + * Apply a direct (non-relational) clause to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + protected function applyDirect(Builder $builder) + { + return $builder->where($this->clause->field, $this->clause->operator, $this->clause->value); + } + + /** + * Apply a relational clause to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + protected function applyRelational(Builder $builder) + { + [$relation, $field] = explode('.', $this->clause->field, 2); + + return $builder->whereHas($relation, function ($query) use ($field) { + $query->where($field, $this->clause->operator, $this->clause->value); + }); + } } diff --git a/src/Engines/Foundation/ClauseFactory.php b/src/Engines/Foundation/ClauseFactory.php index 08e42ff..e22a84d 100644 --- a/src/Engines/Foundation/ClauseFactory.php +++ b/src/Engines/Foundation/ClauseFactory.php @@ -2,143 +2,148 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Kettasoft\Filterable\Support\Payload; -use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedEmptyValueException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; +use Kettasoft\Filterable\Support\Payload; /** - * Class ClauseFactory + * Class ClauseFactory. * * Responsible for building {@see Clause} objects from {@see Payload}, * including field/operator validation and resolution. - * - * @package Kettasoft\Filterable\Engines\Foundation */ class ClauseFactory { - /** - * Create a new ClauseFactory instance. - * @param \Kettasoft\Filterable\Engines\Foundation\Engine $engine - */ - public function __construct(protected Engine $engine) {} - - /** - * Build a Clause from the given Payload. - * - * @param Payload $payload - * @return Clause - * - * @throws NotAllowedFieldException If the field is not allowed and strict mode is enabled. - * @throws InvalidOperatorException If the operator is not allowed and strict mode is enabled. - * @throws \InvalidArgumentException If the value is empty and strict mode is enabled. - */ - public function make(Payload $payload): Clause - { - $this->validateField($payload); - $this->validateOperator($payload); - $this->validateValue($payload); - - $resolvedField = $this->resolveField($payload); - $resolvedOperator = $this->resolveOperator($payload); - $payload->setField($resolvedField)->setOperator($resolvedOperator); - - return (new Clause($payload)); - } - - /** - * Validate the payload field against allowed fields and relations. - * - * @param Payload $payload - * @return void - * - * @throws NotAllowedFieldException - */ - protected function validateField(Payload $payload): void - { - $field = $payload->field; - // allow wildcard * as "all fields allowed" - $isWildcardAllowed = ($this->engine->getAllowedFields()[0] ?? false) === '*'; - if (!(in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field) || $isWildcardAllowed)) { - throw new NotAllowedFieldException($field); + /** + * Create a new ClauseFactory instance. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Engine $engine + */ + public function __construct(protected Engine $engine) + { } - return; - } - - /** - * Validate the payload operator against allowed operators. - * - * @param Payload $payload - * @return bool - * - * @throws InvalidOperatorException - */ - protected function validateOperator(Payload $payload): bool - { - $operator = $payload->operator; - if (! array_key_exists($operator, $this->engine->allowedOperators()) && $this->engine->isStrict()) { - - throw new InvalidOperatorException($operator); + /** + * Build a Clause from the given Payload. + * + * @param Payload $payload + * + * @throws NotAllowedFieldException If the field is not allowed and strict mode is enabled. + * @throws InvalidOperatorException If the operator is not allowed and strict mode is enabled. + * @throws \InvalidArgumentException If the value is empty and strict mode is enabled. + * + * @return Clause + */ + public function make(Payload $payload): Clause + { + $this->validateField($payload); + $this->validateOperator($payload); + $this->validateValue($payload); + + $resolvedField = $this->resolveField($payload); + $resolvedOperator = $this->resolveOperator($payload); + $payload->setField($resolvedField)->setOperator($resolvedOperator); + + return new Clause($payload); } - return (bool) $this->engine->defaultOperator(); - } - - /** - * Validate that the payload value is not empty when ignoredEmptyValues is enabled. - * - * @param Payload $payload - * @return void - * - * @throws NotAllowedEmptyValueException - */ - protected function validateValue(Payload $payload): void - { - if ($this->engine->isIgnoredEmptyValues() && $payload->isEmpty()) { - throw new NotAllowedEmptyValueException("Empty values are not allowed."); + /** + * Validate the payload field against allowed fields and relations. + * + * @param Payload $payload + * + * @throws NotAllowedFieldException + * + * @return void + */ + protected function validateField(Payload $payload): void + { + $field = $payload->field; + // allow wildcard * as "all fields allowed" + $isWildcardAllowed = ($this->engine->getAllowedFields()[0] ?? false) === '*'; + if (!(in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field) || $isWildcardAllowed)) { + throw new NotAllowedFieldException($field); + } + } - return; - } + /** + * Validate the payload operator against allowed operators. + * + * @param Payload $payload + * + * @throws InvalidOperatorException + * + * @return bool + */ + protected function validateOperator(Payload $payload): bool + { + $operator = $payload->operator; + if (!array_key_exists($operator, $this->engine->allowedOperators()) && $this->engine->isStrict()) { + throw new InvalidOperatorException($operator); + } + + return (bool) $this->engine->defaultOperator(); + } + + /** + * Validate that the payload value is not empty when ignoredEmptyValues is enabled. + * + * @param Payload $payload + * + * @throws NotAllowedEmptyValueException + * + * @return void + */ + protected function validateValue(Payload $payload): void + { + if ($this->engine->isIgnoredEmptyValues() && $payload->isEmpty()) { + throw new NotAllowedEmptyValueException('Empty values are not allowed.'); + } + + } - /* ----------------------------------------------------------------- - | Resolution Methods - | ----------------------------------------------------------------- + /* ----------------------------------------------------------------- + | Resolution Methods + | ----------------------------------------------------------------- + */ + + /** + * Resolve the final field name using fields map. + * + * @param Payload $payload + * + * @return string */ + protected function resolveField(Payload $payload): string + { + return $this->engine->getFieldsMap()[$payload->field] ?? $payload->field; + } + + /** + * Resolve the final operator using allowed operators or default operator. + * + * @param Payload $payload + * + * @return string + */ + protected function resolveOperator(Payload $payload): string + { + return $this->engine->allowedOperators()[$payload->operator] + ?? Operators::fromString($this->engine->defaultOperator()); + } - /** - * Resolve the final field name using fields map. - * - * @param Payload $payload - * @return string - */ - protected function resolveField(Payload $payload): string - { - return $this->engine->getFieldsMap()[$payload->field] ?? $payload->field; - } - - /** - * Resolve the final operator using allowed operators or default operator. - * - * @param Payload $payload - * @return string - */ - protected function resolveOperator(Payload $payload): string - { - return $this->engine->allowedOperators()[$payload->operator] - ?? Operators::fromString($this->engine->defaultOperator()); - } - - /** - * Determine if the given field is part of a relation path. - * - * @param string $field - * @return bool - */ - protected function isRelational(string $field): bool - { - return $this->engine->getContext()->hasRelationPath($field); - } + /** + * Determine if the given field is part of a relation path. + * + * @param string $field + * + * @return bool + */ + protected function isRelational(string $field): bool + { + return $this->engine->getContext()->hasRelationPath($field); + } } diff --git a/src/Engines/Foundation/Contracts/Outcome.php b/src/Engines/Foundation/Contracts/Outcome.php index 20eb68c..45d3261 100644 --- a/src/Engines/Foundation/Contracts/Outcome.php +++ b/src/Engines/Foundation/Contracts/Outcome.php @@ -6,43 +6,53 @@ interface Outcome { - /** - * Register a callback to be executed when the outcome is resolved. - * @param \Closure $closure The callback to execute, which receives the outcome's value as an argument. - * @return self - */ - public function then(Closure $closure): self; - - /** - * Register a callback to be executed when the outcome is rejected. - * @param \Closure $closure The callback to execute, which receives the reason for rejection as an argument. - * @return self - */ - public function catch(Closure $closure): self; - - /** - * Register a callback to be executed when the outcome is settled (either resolved or rejected). - * @param \Closure $closure The callback to execute, which receives no arguments. - * @return self - */ - public function finally(Closure $closure): self; - - /** - * Mark the outcome as failed with the given error. - * @param \Throwable $error The error that caused the outcome to be rejected. - * @return self - */ - public function fail(\Throwable $error): self; - - /** - * Check if the outcome is resolved. - * @return bool True if the outcome is resolved, false otherwise. - */ - public function isResolved(): bool; - - /** - * Check if the outcome is rejected. - * @return bool True if the outcome is rejected, false otherwise. - */ - public function isRejected(): bool; + /** + * Register a callback to be executed when the outcome is resolved. + * + * @param \Closure $closure The callback to execute, which receives the outcome's value as an argument. + * + * @return self + */ + public function then(Closure $closure): self; + + /** + * Register a callback to be executed when the outcome is rejected. + * + * @param \Closure $closure The callback to execute, which receives the reason for rejection as an argument. + * + * @return self + */ + public function catch(Closure $closure): self; + + /** + * Register a callback to be executed when the outcome is settled (either resolved or rejected). + * + * @param \Closure $closure The callback to execute, which receives no arguments. + * + * @return self + */ + public function finally(Closure $closure): self; + + /** + * Mark the outcome as failed with the given error. + * + * @param \Throwable $error The error that caused the outcome to be rejected. + * + * @return self + */ + public function fail(\Throwable $error): self; + + /** + * Check if the outcome is resolved. + * + * @return bool True if the outcome is resolved, false otherwise. + */ + public function isResolved(): bool; + + /** + * Check if the outcome is rejected. + * + * @return bool True if the outcome is rejected, false otherwise. + */ + public function isRejected(): bool; } diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index 7538005..3b34de6 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -2,171 +2,185 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Support\Arr; -use Kettasoft\Filterable\Filterable; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Foundation\Resources; -use Kettasoft\Filterable\Engines\Contracts\Skippable; +use Illuminate\Support\Arr; use Kettasoft\Filterable\Engines\Contracts\Executable; -use Kettasoft\Filterable\Engines\Contracts\Strictable; -use Kettasoft\Filterable\Engines\Contracts\HasFieldMap; -use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; use Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker; +use Kettasoft\Filterable\Engines\Contracts\HasFieldMap; use Kettasoft\Filterable\Engines\Contracts\HasInteractsWithOperators; +use Kettasoft\Filterable\Engines\Contracts\Skippable; +use Kettasoft\Filterable\Engines\Contracts\Strictable; +use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Foundation\Resources; abstract class Engine implements HasInteractsWithOperators, HasFieldMap, Strictable, Executable, HasAllowedFieldChecker, Skippable { - /** - * Create Engine instance. - * @param Filterable $context - */ - public function __construct(protected Filterable $context) {} - - /** - * Get engine name. - * @return string - */ - abstract public function getEngineName(): string; - - /** - * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - abstract public function execute(Builder $builder); - - /** - * Attempt to execute the given callback, handling exceptions. - * - * @param \Closure $callback - * @return bool - */ - final protected function attempt(\Closure $callback): bool - { - try { - return $callback->call($this); - } catch (\Throwable $e) { - return $this->context->getExceptionHandler()->handle($e, $this); - } - } - - /** - * @inheritDoc - */ - public function skip(string $message, mixed $clause = null): never - { - throw new SkipExecution($message, $clause); - } - - /** - * Get allowed fields to filtering. - * @return array - */ - protected function getAllowedFieldsFromConfig(): array - { - return config("filterable.engines.{$this->getEngineName()}.allowed_fields", []); - } - - /** - * Check if empty values are ignored from engine config. - * @return bool - */ - protected function isIgnoredEmptyValuesFromConfig(): bool - { - return config("filterable.engines.{$this->getEngineName()}.ignore_empty_values", false); - } - - /** - * Get allowed operators to filtering. - * @return array - */ - public function getOperatorsFromConfig(): array - { - return config("filterable.engines.{$this->getEngineName()}.allowed_operators", []); - } - - /** - * Check if the strict mode is enable in an engine config. - * @return bool - */ - protected function isStrictFromConfig(): bool - { - return config("filterable.engines.{$this->getEngineName()}.strict", false); - } - - public function isIgnoredEmptyValues(): bool - { - return $this->isIgnoredEmptyValuesFromConfig() || $this->context->hasIgnoredEmptyValues(); - } - - public function getAllowedFields(): array - { - return array_merge($this->getAllowedFieldsFromConfig(), $this->context->getAllowedFields()); - } - - /** - * @inheritDoc - */ - public function allowedOperators(): array - { - if (empty($this->context->getAllowedOperators())) { - return $this->getOperatorsFromConfig(); - } - - return Arr::only($this->getOperatorsFromConfig(), $this->context->getAllowedOperators()); - } - - /** - * @inheritDoc - */ - public function getFieldsMap(): array - { - return $this->context->getFieldsMap(); - } - - /** - * @inheritDoc - */ - public function isStrict(): bool - { - return is_bool($this->context->isStrict()) ? $this->context->isStrict() : $this->isStrictFromConfig(); - } - - /** - * Get the context instance. - * @return Filterable - */ - public function getContext(): Filterable - { - return $this->context; - } - - public function getResources(): Resources - { - return $this->context->getResources(); - } - - /** - * Sanitize the given value using the sanitizer instance. - * - * @param mixed $filed - * @param mixed $value - */ - final protected function sanitizeValue($filed, $value) - { - $sanitizer = $this->context->getSanitizerInstance(); - - return $sanitizer->handle($filed, $value); - } - - /** - * Commit applied clauses. - * @param string $key - * @param Clause $clause - * @return bool - */ - final protected function commit(string $key, Clause $clause): bool - { - return $this->context->commit($key, $clause); - } + /** + * Create Engine instance. + * + * @param Filterable $context + */ + public function __construct(protected Filterable $context) + { + } + + /** + * Get engine name. + * + * @return string + */ + abstract public function getEngineName(): string; + + /** + * Apply filters to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + abstract public function execute(Builder $builder); + + /** + * Attempt to execute the given callback, handling exceptions. + * + * @param \Closure $callback + * + * @return bool + */ + final protected function attempt(\Closure $callback): bool + { + try { + return $callback->call($this); + } catch (\Throwable $e) { + return $this->context->getExceptionHandler()->handle($e, $this); + } + } + + /** + * @inheritDoc + */ + public function skip(string $message, mixed $clause = null): never + { + throw new SkipExecution($message, $clause); + } + + /** + * Get allowed fields to filtering. + * + * @return array + */ + protected function getAllowedFieldsFromConfig(): array + { + return config("filterable.engines.{$this->getEngineName()}.allowed_fields", []); + } + + /** + * Check if empty values are ignored from engine config. + * + * @return bool + */ + protected function isIgnoredEmptyValuesFromConfig(): bool + { + return config("filterable.engines.{$this->getEngineName()}.ignore_empty_values", false); + } + + /** + * Get allowed operators to filtering. + * + * @return array + */ + public function getOperatorsFromConfig(): array + { + return config("filterable.engines.{$this->getEngineName()}.allowed_operators", []); + } + + /** + * Check if the strict mode is enable in an engine config. + * + * @return bool + */ + protected function isStrictFromConfig(): bool + { + return config("filterable.engines.{$this->getEngineName()}.strict", false); + } + + public function isIgnoredEmptyValues(): bool + { + return $this->isIgnoredEmptyValuesFromConfig() || $this->context->hasIgnoredEmptyValues(); + } + + public function getAllowedFields(): array + { + return array_merge($this->getAllowedFieldsFromConfig(), $this->context->getAllowedFields()); + } + + /** + * @inheritDoc + */ + public function allowedOperators(): array + { + if (empty($this->context->getAllowedOperators())) { + return $this->getOperatorsFromConfig(); + } + + return Arr::only($this->getOperatorsFromConfig(), $this->context->getAllowedOperators()); + } + + /** + * @inheritDoc + */ + public function getFieldsMap(): array + { + return $this->context->getFieldsMap(); + } + + /** + * @inheritDoc + */ + public function isStrict(): bool + { + return is_bool($this->context->isStrict()) ? $this->context->isStrict() : $this->isStrictFromConfig(); + } + + /** + * Get the context instance. + * + * @return Filterable + */ + public function getContext(): Filterable + { + return $this->context; + } + + public function getResources(): Resources + { + return $this->context->getResources(); + } + + /** + * Sanitize the given value using the sanitizer instance. + * + * @param mixed $filed + * @param mixed $value + */ + final protected function sanitizeValue($filed, $value) + { + $sanitizer = $this->context->getSanitizerInstance(); + + return $sanitizer->handle($filed, $value); + } + + /** + * Commit applied clauses. + * + * @param string $key + * @param Clause $clause + * + * @return bool + */ + final protected function commit(string $key, Clause $clause): bool + { + return $this->context->commit($key, $clause); + } } diff --git a/src/Engines/Foundation/Enums/Operators.php b/src/Engines/Foundation/Enums/Operators.php index 36cb46b..d3adce3 100644 --- a/src/Engines/Foundation/Enums/Operators.php +++ b/src/Engines/Foundation/Enums/Operators.php @@ -6,40 +6,40 @@ enum Operators: string { - case EQUALS = '='; - case NOT_EQUALS = '!='; - case GREATER_THAN = '>'; - case LESS_THAN = '<'; - case GREATER_THAN_OR_EQUAL = '>='; - case LESS_THAN_OR_EQUAL = '<='; - case LIKE = 'LIKE'; - case NOT_LIKE = 'NOT LIKE'; - case IN = 'IN'; - case NOT_IN = 'NOT IN'; - case IS_NULL = 'IS NULL'; - case IS_NOT_NULL = 'IS NOT NULL'; + case EQUALS = '='; + case NOT_EQUALS = '!='; + case GREATER_THAN = '>'; + case LESS_THAN = '<'; + case GREATER_THAN_OR_EQUAL = '>='; + case LESS_THAN_OR_EQUAL = '<='; + case LIKE = 'LIKE'; + case NOT_LIKE = 'NOT LIKE'; + case IN = 'IN'; + case NOT_IN = 'NOT IN'; + case IS_NULL = 'IS NULL'; + case IS_NOT_NULL = 'IS NOT NULL'; - public function toString(): string - { - return $this->value; - } + public function toString(): string + { + return $this->value; + } - public static function fromString(string $operator): string - { - return match ($operator) { - 'eq' => self::EQUALS->value, - 'ne' => self::NOT_EQUALS->value, - 'gt' => self::GREATER_THAN->value, - 'lt' => self::LESS_THAN->value, - 'gte' => self::GREATER_THAN_OR_EQUAL->value, - 'lte' => self::LESS_THAN_OR_EQUAL->value, - 'like' => self::LIKE->value, - 'not_like' => self::NOT_LIKE->value, - 'in' => self::IN->value, - 'not_in' => self::NOT_IN->value, - 'is_null' => self::IS_NULL->value, - 'is_not_null' => self::IS_NOT_NULL->value, - default => throw new InvalidOperatorException($operator), - }; - } + public static function fromString(string $operator): string + { + return match ($operator) { + 'eq' => self::EQUALS->value, + 'ne' => self::NOT_EQUALS->value, + 'gt' => self::GREATER_THAN->value, + 'lt' => self::LESS_THAN->value, + 'gte' => self::GREATER_THAN_OR_EQUAL->value, + 'lte' => self::LESS_THAN_OR_EQUAL->value, + 'like' => self::LIKE->value, + 'not_like' => self::NOT_LIKE->value, + 'in' => self::IN->value, + 'not_in' => self::NOT_IN->value, + 'is_null' => self::IS_NULL->value, + 'is_not_null' => self::IS_NOT_NULL->value, + default => throw new InvalidOperatorException($operator), + }; + } } diff --git a/src/Engines/Foundation/Execution.php b/src/Engines/Foundation/Execution.php index 5d67dca..376791a 100644 --- a/src/Engines/Foundation/Execution.php +++ b/src/Engines/Foundation/Execution.php @@ -7,76 +7,88 @@ class Execution implements Contracts\Outcome { - /** - * The error that caused the outcome to be rejected. - * @var \Throwable|null - */ - private \Throwable|null $error = null; + /** + * The error that caused the outcome to be rejected. + * + * @var \Throwable|null + */ + private ?\Throwable $error = null; - /** - * Create a new Execution instance. - * @param \Throwable|null $error The error that caused the outcome to be rejected, or null if the outcome is resolved. - */ - public function then(Closure $closure): Outcome - { - if (! $this->isRejected()) { - $closure(); + /** + * Create a new Execution instance. + * + * @param \Throwable|null $error The error that caused the outcome to be rejected, or null if the outcome is resolved. + */ + public function then(Closure $closure): Outcome + { + if (!$this->isRejected()) { + $closure(); + } + + return $this; } - return $this; - } + /** + * Register a callback to be executed when the outcome is rejected. + * + * @param \Closure $closure The callback to execute, which receives the reason for rejection as an argument. + * + * @return self + */ + public function catch(Closure $closure): Outcome + { + if ($this->isRejected()) { + $closure($this->error); + } - /** - * Register a callback to be executed when the outcome is rejected. - * @param \Closure $closure The callback to execute, which receives the reason for rejection as an argument. - * @return self - */ - public function catch(Closure $closure): Outcome - { - if ($this->isRejected()) { - $closure($this->error); + return $this; } - return $this; - } + /** + * Register a callback to be executed when the outcome is settled (either resolved or rejected). + * + * @param \Closure $closure The callback to execute, which receives no arguments. + * + * @return self + */ + public function finally(Closure $closure): Outcome + { + $closure(); + + return $this; + } - /** - * Register a callback to be executed when the outcome is settled (either resolved or rejected). - * @param \Closure $closure The callback to execute, which receives no arguments. - * @return self - */ - public function finally(Closure $closure): Outcome - { - $closure(); - return $this; - } + /** + * Mark the outcome as failed with the given error. + * + * @param \Throwable $error The error that caused the outcome to be rejected. + * + * @return self + */ + public function fail(\Throwable $error): Outcome + { + $this->error = $error; - /** - * Mark the outcome as failed with the given error. - * @param \Throwable $error The error that caused the outcome to be rejected. - * @return self - */ - public function fail(\Throwable $error): Outcome - { - $this->error = $error; - return $this; - } + return $this; + } - /** - * Check if the outcome is resolved. - * @return bool True if the outcome is resolved, false otherwise. - */ - public function isResolved(): bool - { - return $this->error === null; - } + /** + * Check if the outcome is resolved. + * + * @return bool True if the outcome is resolved, false otherwise. + */ + public function isResolved(): bool + { + return $this->error === null; + } - /** - * Check if the outcome is rejected. - * @return bool True if the outcome is rejected, false otherwise. - */ - public function isRejected(): bool - { - return !$this->isResolved(); - } + /** + * Check if the outcome is rejected. + * + * @return bool True if the outcome is rejected, false otherwise. + */ + public function isRejected(): bool + { + return !$this->isResolved(); + } } diff --git a/src/Engines/Foundation/Executors/Executer.php b/src/Engines/Foundation/Executors/Executer.php index 627bbc4..031a6ea 100644 --- a/src/Engines/Foundation/Executors/Executer.php +++ b/src/Engines/Foundation/Executors/Executer.php @@ -7,14 +7,16 @@ trait Executer { - /** - * Execute the given Executable instance with the provided query builder instance. - * @param \Kettasoft\Filterable\Engines\Contracts\Executable $executable - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return \Illuminate\Contracts\Database\Eloquent\Builder - */ - public static function execute(Executable $executable, Builder $builder) - { - return $executable->execute($builder); - } + /** + * Execute the given Executable instance with the provided query builder instance. + * + * @param \Kettasoft\Filterable\Engines\Contracts\Executable $executable + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return \Illuminate\Contracts\Database\Eloquent\Builder + */ + public static function execute(Executable $executable, Builder $builder) + { + return $executable->execute($builder); + } } diff --git a/src/Engines/Foundation/Handlers/AllowedFieldValidator.php b/src/Engines/Foundation/Handlers/AllowedFieldValidator.php index 11833ab..f58e7a6 100644 --- a/src/Engines/Foundation/Handlers/AllowedFieldValidator.php +++ b/src/Engines/Foundation/Handlers/AllowedFieldValidator.php @@ -2,38 +2,44 @@ namespace Kettasoft\Filterable\Engines\Foundation\Handlers; -use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Foundation\Engine; /** * Validate if field is allowed to apply filtering. */ class AllowedFieldValidator { - /** - * Check if field is allowed to apply filtering. - * @param \Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker $engine - * @param mixed $field - * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException - * @return bool - */ - final public static function validate(Engine $engine, $field) - { - if (! in_array($field, $engine->getAllowedFields())) { - return $engine->isStrict() ? self::throw($field) : false; - } + /** + * Check if field is allowed to apply filtering. + * + * @param \Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker $engine + * @param mixed $field + * + * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException + * + * @return bool + */ + final public static function validate(Engine $engine, $field) + { + if (!in_array($field, $engine->getAllowedFields())) { + return $engine->isStrict() ? self::throw($field) : false; + } - return true; - } + return true; + } - /** - * Throw field is not allowed. - * @param mixed $field - * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException - * @return never - */ - protected static function throw($field) - { - throw new NotAllowedFieldException($field); - } + /** + * Throw field is not allowed. + * + * @param mixed $field + * + * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException + * + * @return never + */ + protected static function throw($field) + { + throw new NotAllowedFieldException($field); + } } diff --git a/src/Engines/Foundation/Mapper.php b/src/Engines/Foundation/Mapper.php index 652f981..ba32ca1 100644 --- a/src/Engines/Foundation/Mapper.php +++ b/src/Engines/Foundation/Mapper.php @@ -6,8 +6,8 @@ trait Mapper { - public static function run(Mappable $mappable, $args = null) - { - return $mappable->map($args); - } + public static function run(Mappable $mappable, $args = null) + { + return $mappable->map($args); + } } diff --git a/src/Engines/Foundation/Mappers/ClauseKeyMapper.php b/src/Engines/Foundation/Mappers/ClauseKeyMapper.php index 6be90cd..685e5cb 100644 --- a/src/Engines/Foundation/Mappers/ClauseKeyMapper.php +++ b/src/Engines/Foundation/Mappers/ClauseKeyMapper.php @@ -7,82 +7,89 @@ */ class ClauseKeyMapper { - protected readonly string $fieldKey; - protected readonly string $operatorKey; - protected readonly string $valueKey; - protected readonly array $keys; - protected array $defaults = [ - 'field' => 'field', - 'operator' => 'operator', - 'value' => 'value', - ]; + protected readonly string $fieldKey; + protected readonly string $operatorKey; + protected readonly string $valueKey; + protected readonly array $keys; + protected array $defaults = [ + 'field' => 'field', + 'operator' => 'operator', + 'value' => 'value', + ]; - /** - * ClauseKeyMapper constructor - * @param array $keys - */ - public function __construct(array $keys = []) - { - $keys = array_merge(config('filterable.clause_keys', $this->defaults), $keys); + /** + * ClauseKeyMapper constructor. + * + * @param array $keys + */ + public function __construct(array $keys = []) + { + $keys = array_merge(config('filterable.clause_keys', $this->defaults), $keys); - $this->validateKeys($keys); + $this->validateKeys($keys); - $this->fieldKey = $keys['field']; - $this->operatorKey = $keys['operator']; - $this->valueKey = $keys['value']; - $this->keys = $keys; - } + $this->fieldKey = $keys['field']; + $this->operatorKey = $keys['operator']; + $this->valueKey = $keys['value']; + $this->keys = $keys; + } - /** - * Get the key for the field name. - * @return string - */ - public function field(): string - { - return $this->fieldKey; - } + /** + * Get the key for the field name. + * + * @return string + */ + public function field(): string + { + return $this->fieldKey; + } - /** - * Get the key for the operator. - * @return string - */ - public function operator(): string - { - return $this->operatorKey; - } + /** + * Get the key for the operator. + * + * @return string + */ + public function operator(): string + { + return $this->operatorKey; + } - /** - * Get the key for the value. - * @return string - */ - public function value(): string - { - return $this->valueKey; - } + /** + * Get the key for the value. + * + * @return string + */ + public function value(): string + { + return $this->valueKey; + } - /** - * Get the full keys. - * @return array - */ - public function all(): array - { - return $this->keys; - } + /** + * Get the full keys. + * + * @return array + */ + public function all(): array + { + return $this->keys; + } - /** - * Validate that custom keys are unique. - * @param array $keys - * @throws \InvalidArgumentException - */ - protected function validateKeys(array $keys) - { - $values = array_values($keys); - $unique = array_unique($values); + /** + * Validate that custom keys are unique. + * + * @param array $keys + * + * @throws \InvalidArgumentException + */ + protected function validateKeys(array $keys) + { + $values = array_values($keys); + $unique = array_unique($values); - if (count($values) !== count($unique)) { - $dublicates = array_diff_key($values, $unique); + if (count($values) !== count($unique)) { + $dublicates = array_diff_key($values, $unique); - throw new \InvalidArgumentException(sprintf("Custom clause keys must be unique. Conflict in: %s", implode(', ', array_keys($dublicates)))); + throw new \InvalidArgumentException(sprintf('Custom clause keys must be unique. Conflict in: %s', implode(', ', array_keys($dublicates)))); + } } - } } diff --git a/src/Engines/Foundation/Mappers/FieldMapper.php b/src/Engines/Foundation/Mappers/FieldMapper.php index d9bd398..52acdf7 100644 --- a/src/Engines/Foundation/Mappers/FieldMapper.php +++ b/src/Engines/Foundation/Mappers/FieldMapper.php @@ -2,33 +2,39 @@ namespace Kettasoft\Filterable\Engines\Foundation\Mappers; -use Kettasoft\Filterable\Engines\Contracts\Mappable; use Kettasoft\Filterable\Engines\Contracts\HasFieldMap; +use Kettasoft\Filterable\Engines\Contracts\Mappable; class FieldMapper implements Mappable { - /** - * FieldMapper constructor. - * @param \Kettasoft\Filterable\Engines\Contracts\HasFieldMap $context - */ - public function __construct(protected HasFieldMap $context) {} + /** + * FieldMapper constructor. + * + * @param \Kettasoft\Filterable\Engines\Contracts\HasFieldMap $context + */ + public function __construct(protected HasFieldMap $context) + { + } - /** - * Create new FieldMapper instance. - * @param \Kettasoft\Filterable\Engines\Contracts\HasFieldMap $engine - * @return FieldMapper - */ - public static function init(HasFieldMap $engine) - { - return new self($engine); - } + /** + * Create new FieldMapper instance. + * + * @param \Kettasoft\Filterable\Engines\Contracts\HasFieldMap $engine + * + * @return FieldMapper + */ + public static function init(HasFieldMap $engine) + { + return new self($engine); + } - /** - * Mapping field to real database column name. - * @param string $field - */ - public function map(string|null $field = null): string - { - return $this->context->getFieldsMap()[$field] ?? $field; - } + /** + * Mapping field to real database column name. + * + * @param string $field + */ + public function map(?string $field = null): string + { + return $this->context->getFieldsMap()[$field] ?? $field; + } } diff --git a/src/Engines/Foundation/Mappers/OperatorMapper.php b/src/Engines/Foundation/Mappers/OperatorMapper.php index 4ed6e5e..733d847 100644 --- a/src/Engines/Foundation/Mappers/OperatorMapper.php +++ b/src/Engines/Foundation/Mappers/OperatorMapper.php @@ -3,33 +3,38 @@ namespace Kettasoft\Filterable\Engines\Foundation\Mappers; use Kettasoft\Filterable\Engines\Contracts\Mappable; -use Kettasoft\Filterable\Engines\Foundation\OperatorDefinition; use Kettasoft\Filterable\Engines\Contracts\OperatorDefinitionContract; +use Kettasoft\Filterable\Engines\Foundation\OperatorDefinition; use Kettasoft\Filterable\Foundation\Bags\OperatorBag; class OperatorMapper implements Mappable { - /** - * OperatorMapper constructor. - * @param \Kettasoft\Filterable\Engines\Contracts\OperatorDefinitionContract $definition - */ - public function __construct(protected OperatorDefinitionContract $definition, protected bool $strict) {} + /** + * OperatorMapper constructor. + * + * @param \Kettasoft\Filterable\Engines\Contracts\OperatorDefinitionContract $definition + */ + public function __construct(protected OperatorDefinitionContract $definition, protected bool $strict) + { + } - /** - * Init OperatorMapper instance. - * @param \Kettasoft\Filterable\Foundation\Bags\OperatorBag $bag - * @return self - */ - public static function init(OperatorBag $bag, bool $isStrict): self - { - return new self(new OperatorDefinition($bag, $isStrict), $isStrict); - } + /** + * Init OperatorMapper instance. + * + * @param \Kettasoft\Filterable\Foundation\Bags\OperatorBag $bag + * + * @return self + */ + public static function init(OperatorBag $bag, bool $isStrict): self + { + return new self(new OperatorDefinition($bag, $isStrict), $isStrict); + } - /** - * @inheritDoc - */ - public function map(string|null $operator = null): string|null - { - return $this->definition->resolve($operator); - } + /** + * @inheritDoc + */ + public function map(?string $operator = null): ?string + { + return $this->definition->resolve($operator); + } } diff --git a/src/Engines/Foundation/Mappers/RelationMapper.php b/src/Engines/Foundation/Mappers/RelationMapper.php index 6450173..2314672 100644 --- a/src/Engines/Foundation/Mappers/RelationMapper.php +++ b/src/Engines/Foundation/Mappers/RelationMapper.php @@ -2,4 +2,6 @@ namespace Kettasoft\Filterable\Engines\Foundation\Mappers; -class RelationMapper {} +class RelationMapper +{ +} diff --git a/src/Engines/Foundation/OperatorDefinition.php b/src/Engines/Foundation/OperatorDefinition.php index fdd53a0..a279f1f 100644 --- a/src/Engines/Foundation/OperatorDefinition.php +++ b/src/Engines/Foundation/OperatorDefinition.php @@ -2,67 +2,71 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Support\Arr; -use Kettasoft\Filterable\Foundation\Bags\OperatorBag; -use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; -use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Engines\Contracts\HasInteractsWithOperators; use Kettasoft\Filterable\Engines\Contracts\OperatorDefinitionContract; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; +use Kettasoft\Filterable\Foundation\Bags\OperatorBag; class OperatorDefinition implements OperatorDefinitionContract { - /** - * @inheritDoc - */ - public function __construct(protected OperatorBag $bag, protected bool $strict) {} + /** + * @inheritDoc + */ + public function __construct(protected OperatorBag $bag, protected bool $strict) + { + } - /** - * @inheritDoc - */ - public function isAllowed(string $operator): bool - { - return $this->bag->has($operator); - } + /** + * @inheritDoc + */ + public function isAllowed(string $operator): bool + { + return $this->bag->has($operator); + } - /** - * @inheritDoc - */ - public function resolve(string|null $operator = null): string|null - { - $operator = strtolower($operator); + /** + * @inheritDoc + */ + public function resolve(?string $operator = null): ?string + { + $operator = strtolower($operator); - if ($this->isAllowed($operator)) { - return $this->bag->get($operator); - } + if ($this->isAllowed($operator)) { + return $this->bag->get($operator); + } - if ($this->strict) { - $this->throw($operator); + if ($this->strict) { + $this->throw($operator); + } + + return Operators::fromString($this->bag->default); } - return Operators::fromString($this->bag->default); - } + /** + * Get all defined operators. + * + * @return array + */ + public function all($key = null): array|string|null + { + if ($key) { + return $this->bag->get($key); + } - /** - * Get all defined operators - * @return array - */ - public function all($key = null): array|string|null - { - if ($key) { - return $this->bag->get($key); + return $this->bag->all(); } - return $this->bag->all(); - } - - /** - * Throw InvalidOperatorException instance. - * @param mixed $operator - * @throws \Kettasoft\Filterable\Exceptions\InvalidOperatorException - * @return never - */ - protected function throw($operator) - { - throw new InvalidOperatorException($operator); - } + /** + * Throw InvalidOperatorException instance. + * + * @param mixed $operator + * + * @throws \Kettasoft\Filterable\Exceptions\InvalidOperatorException + * + * @return never + */ + protected function throw($operator) + { + throw new InvalidOperatorException($operator); + } } diff --git a/src/Engines/Foundation/Parsers/Dissector.php b/src/Engines/Foundation/Parsers/Dissector.php index e0a1edb..9b34c39 100644 --- a/src/Engines/Foundation/Parsers/Dissector.php +++ b/src/Engines/Foundation/Parsers/Dissector.php @@ -4,79 +4,82 @@ /** * Dissector class for parsing raw filter values and operators. - * + * * This class is responsible for extracting and normalizing operator and value * pairs from various input formats (array, string, or scalar values). - * - * @package Kettasoft\Filterable\Engines\Foundation\Parsers */ class Dissector { - /** - * Parsed value. - * @var mixed - */ - public readonly mixed $value; + /** + * Parsed value. + * + * @var mixed + */ + public readonly mixed $value; - /** - * Parsed operator. - * @var string|null - */ - public readonly string|null $operator; + /** + * Parsed operator. + * + * @var string|null + */ + public readonly ?string $operator; - /** - * Parse the given raw input into operator and value components. - * - * Supports multiple input formats: - * - Array: ['operator' => 'eq', 'value' => 'some_value'] - * - String with delimiter: 'eq:some_value' - * - String without delimiter: uses defaultOperator with the string as value - * - Null: uses defaultOperator with null as value - * - Other scalar values: uses defaultOperator with the value as-is - * - * @param mixed $raw The raw input to parse (array, string, or scalar) - * @param mixed $defaultOperator The default operator to use when not specified - * @return self A new Dissector instance with parsed operator and value - */ - public static function parse(mixed $raw, mixed $defaultOperator): self - { - $instance = new self(); - [$operator, $value] = self::extractOperatorAndValue($raw, $defaultOperator); + /** + * Parse the given raw input into operator and value components. + * + * Supports multiple input formats: + * - Array: ['operator' => 'eq', 'value' => 'some_value'] + * - String with delimiter: 'eq:some_value' + * - String without delimiter: uses defaultOperator with the string as value + * - Null: uses defaultOperator with null as value + * - Other scalar values: uses defaultOperator with the value as-is + * + * @param mixed $raw The raw input to parse (array, string, or scalar) + * @param mixed $defaultOperator The default operator to use when not specified + * + * @return self A new Dissector instance with parsed operator and value + */ + public static function parse(mixed $raw, mixed $defaultOperator): self + { + $instance = new self(); + [$operator, $value] = self::extractOperatorAndValue($raw, $defaultOperator); - $instance->operator = $operator; - $instance->value = $value; + $instance->operator = $operator; + $instance->value = $value; - return $instance; - } - - /** - * Extract operator and value from various input formats. - * - * @param mixed $raw The raw input to extract from - * @param mixed $defaultOperator The default operator to use as fallback - * @return array A tuple of [operator, value] - */ - protected static function extractOperatorAndValue(mixed $raw, mixed $defaultOperator): array - { - if (is_array($raw) && self::isValidHaystack($raw)) { - return [$raw['operator'], $raw['value']]; + return $instance; } - if (is_string($raw) && str_contains($raw, ':')) { - return explode(':', $raw, 2); - } + /** + * Extract operator and value from various input formats. + * + * @param mixed $raw The raw input to extract from + * @param mixed $defaultOperator The default operator to use as fallback + * + * @return array A tuple of [operator, value] + */ + protected static function extractOperatorAndValue(mixed $raw, mixed $defaultOperator): array + { + if (is_array($raw) && self::isValidHaystack($raw)) { + return [$raw['operator'], $raw['value']]; + } - return [$defaultOperator, $raw]; - } + if (is_string($raw) && str_contains($raw, ':')) { + return explode(':', $raw, 2); + } - /** - * Validate if an array contains the required 'operator' and 'value' keys. - * - * @param array $haystack The array to validate - * @return bool True if the array has both 'operator' and 'value' keys, false otherwise - */ - protected static function isValidHaystack(array $haystack): bool - { - return array_key_exists('operator', $haystack) && array_key_exists('value', $haystack); - } + return [$defaultOperator, $raw]; + } + + /** + * Validate if an array contains the required 'operator' and 'value' keys. + * + * @param array $haystack The array to validate + * + * @return bool True if the array has both 'operator' and 'value' keys, false otherwise + */ + protected static function isValidHaystack(array $haystack): bool + { + return array_key_exists('operator', $haystack) && array_key_exists('value', $haystack); + } } diff --git a/src/Engines/Foundation/Resolvers/RelationResolver.php b/src/Engines/Foundation/Resolvers/RelationResolver.php index 5244581..1caca75 100644 --- a/src/Engines/Foundation/Resolvers/RelationResolver.php +++ b/src/Engines/Foundation/Resolvers/RelationResolver.php @@ -8,49 +8,50 @@ class RelationResolver { - protected string $relationPath; - protected string $field; - - public function __construct(protected RelationBag $bag, protected string $path) - { - $this->parse($path); - } - - public function isAllowed() - { - if ($this->bag->has($this->relationPath)) { - $relation = $this->bag->get($this->relationPath); - // dd($relation); - return is_array($relation) ? (in_array($this->field, $relation)) : true; + protected string $relationPath; + protected string $field; + + public function __construct(protected RelationBag $bag, protected string $path) + { + $this->parse($path); } - return false; - } - - protected function parse(string $path) - { - $segments = explode('.', $this->path); - $field = array_pop($segments); - $path = implode('.', $segments); - - $this->field = $field; - $this->relationPath = $path; - } - - public function getRelationPath(): string - { - return $this->relationPath; - } - - public function getField() - { - return $this->field; - } - - public function resolve(Builder $builder, Clause $clause) - { - return $builder->whereHas($this->relationPath, function (Builder $sub) use ($clause): Builder { - return $sub->where($this->field, $clause->operator, $clause->value); - }); - } + public function isAllowed() + { + if ($this->bag->has($this->relationPath)) { + $relation = $this->bag->get($this->relationPath); + + // dd($relation); + return is_array($relation) ? (in_array($this->field, $relation)) : true; + } + + return false; + } + + protected function parse(string $path) + { + $segments = explode('.', $this->path); + $field = array_pop($segments); + $path = implode('.', $segments); + + $this->field = $field; + $this->relationPath = $path; + } + + public function getRelationPath(): string + { + return $this->relationPath; + } + + public function getField() + { + return $this->field; + } + + public function resolve(Builder $builder, Clause $clause) + { + return $builder->whereHas($this->relationPath, function (Builder $sub) use ($clause): Builder { + return $sub->where($this->field, $clause->operator, $clause->value); + }); + } } diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 10f93cb..792d4e8 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -2,129 +2,138 @@ 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\Filterable; +use Kettasoft\Filterable\Support\Payload; class Invokable extends Engine { - use ForwardsCalls; - - /** - * Engine name. - * @var string - */ - protected $name = 'invokable'; - - /** - * The Eloquent builder instance. - * @var Builder - */ - protected Builder $builder; - - /** - * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - public function execute(Builder $builder): Builder - { - $this->builder = $builder; - - // Set allowed fields from $filters property automatically. - $this->context->setAllowedFields($this->context->getFilterAttributes()); - - foreach ($this->context->getFilterAttributes() as $filter) { - $this->attempt(function () use ($filter) { - $dissector = Dissector::parse($this->context->getRequest()->get($filter), $this->defaultOperator()); + use ForwardsCalls; + + /** + * Engine name. + * + * @var string + */ + protected $name = 'invokable'; + + /** + * The Eloquent builder instance. + * + * @var Builder + */ + protected Builder $builder; + + /** + * Apply filters to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + public function execute(Builder $builder): Builder + { + $this->builder = $builder; + + // Set allowed fields from $filters property automatically. + $this->context->setAllowedFields($this->context->getFilterAttributes()); + + foreach ($this->context->getFilterAttributes() as $filter) { + $this->attempt(function () use ($filter) { + $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); + }); + } - $payload = new Payload($filter, $dissector->operator, $this->sanitizeValue($filter, $dissector->value), $dissector->value); + return $this->builder; + } - $clause = (new ClauseFactory($this))->make($payload); + /** + * Initialize the filter methods and resolve value. + * + * @param string $key + * @param string $method + * @param Payload $payload + * + * @return void + */ + protected function applyFilterMethod(string $key, string $method, Payload $payload): void + { + if (!method_exists($this->context, $method)) { + return; + } - $method = $this->getMethodName($filter); + $attrContext = new AttributeContext( + $this->builder, + $payload, + state: ['method' => $method, 'key' => $key] + ); + + $pipeline = new AttributePipeline($attrContext); + $process = $pipeline->process($this->context, $method); + + $process->then(function () use ($method, $payload) { + $this->forwardCallTo($this->context, $method, [$payload]); + }) + ->catch(function ($e) { + throw $e; + }); + } - // 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])); + /** + * Get method name. + * + * @param string $filter + * + * @return string + */ + protected function getMethodName(string $filter): string + { + if (array_key_exists($filter, $this->context->getMentors())) { + return $this->context->getMentors()[$filter]; } - $this->applyFilterMethod($filter, $method, $payload); - - $this->commit($method, $clause); - }); + return $this->context->getRequest()->has($filter) ? Str::camel($filter) : 'default'.Str::studly($filter); } - return $this->builder; - } - - /** - * Initialize the filter methods and resolve value. - * @param string $key - * @param string $method - * @param Payload $payload - * @return void - */ - protected function applyFilterMethod(string $key, string $method, Payload $payload): void - { - if (! method_exists($this->context, $method)) { - return; + /** + * Get engine default operator. + * + * @return string + */ + public function defaultOperator(): string + { + return config('filterable.engines.invokable.default_operator', 'eq'); } - $attrContext = new AttributeContext( - $this->builder, - $payload, - state: ['method' => $method, 'key' => $key] - ); - - $pipeline = new AttributePipeline($attrContext); - $process = $pipeline->process($this->context, $method); - - $process->then(function () use ($method, $payload) { - $this->forwardCallTo($this->context, $method, [$payload]); - }) - ->catch(function ($e) { - throw $e; - }); - } - - /** - * Get method name. - * @param string $filter - * @return string - */ - protected function getMethodName(string $filter): string - { - if (array_key_exists($filter, $this->context->getMentors())) { - return $this->context->getMentors()[$filter]; + /** + * Get engine name. + * + * @return string + */ + public function getEngineName(): string + { + return $this->name; } - - return $this->context->getRequest()->has($filter) ? Str::camel($filter) : 'default' . Str::studly($filter); - } - - /** - * Get engine default operator. - * @return string - */ - public function defaultOperator(): string - { - return config('filterable.engines.invokable.default_operator', 'eq'); - } - - /** - * Get engine name. - * @return string - */ - public function getEngineName(): string - { - return $this->name; - } } diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index 5c64fca..ca0a496 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -3,74 +3,80 @@ namespace Kettasoft\Filterable\Engines; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\Payload; -use Kettasoft\Filterable\Traits\FieldNormalizer; -use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; use Kettasoft\Filterable\Engines\Foundation\ClauseApplier; use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; -use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; +use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; +use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Traits\FieldNormalizer; class Ruleset extends Engine { - use FieldNormalizer; + use FieldNormalizer; - /** - * Engine name. - * @var string - */ - protected $name = 'ruleset'; + /** + * Engine name. + * + * @var string + */ + protected $name = 'ruleset'; - /** - * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - public function execute(Builder $builder): Builder - { - $data = $this->context->getData(); + /** + * Apply filters to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + public function execute(Builder $builder): Builder + { + $data = $this->context->getData(); - foreach ($data as $field => $dissector) { - $this->attempt(function () use ($builder, $dissector, $field): bool { - $dissector = Dissector::parse($dissector, $this->defaultOperator()); + foreach ($data as $field => $dissector) { + $this->attempt(function () use ($builder, $dissector, $field): bool { + $dissector = Dissector::parse($dissector, $this->defaultOperator()); - $clause = (new ClauseFactory($this))->make( - new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) - ); + $clause = (new ClauseFactory($this))->make( + new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) + ); - Applier::apply(new ClauseApplier($clause), $builder); + Applier::apply(new ClauseApplier($clause), $builder); - return $this->commit($field, $clause); - }); - } + return $this->commit($field, $clause); + }); + } - return $builder; - } + return $builder; + } - /** - * Check if normalize field option is enable in engine. - * @return bool - */ - protected function hasNormalizeFieldCondition(): bool - { - return config('filterable.engines.ruleset.normalize_keys', false); - } + /** + * Check if normalize field option is enable in engine. + * + * @return bool + */ + protected function hasNormalizeFieldCondition(): bool + { + return config('filterable.engines.ruleset.normalize_keys', false); + } - /** - * Get engine default operator. - * @return string - */ - public function defaultOperator(): string - { - return config('filterable.engines.ruleset.default_operator', 'eq'); - } + /** + * Get engine default operator. + * + * @return string + */ + public function defaultOperator(): string + { + return config('filterable.engines.ruleset.default_operator', 'eq'); + } - /** - * Get engine name. - * @return string - */ - public function getEngineName(): string - { - return $this->name; - } + /** + * Get engine name. + * + * @return string + */ + public function getEngineName(): string + { + return $this->name; + } } diff --git a/src/Engines/Tree.php b/src/Engines/Tree.php index d851da2..7c1d888 100644 --- a/src/Engines/Tree.php +++ b/src/Engines/Tree.php @@ -3,98 +3,105 @@ namespace Kettasoft\Filterable\Engines; use Illuminate\Contracts\Database\Eloquent\Builder; +use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; +use Kettasoft\Filterable\Engines\Foundation\ClauseApplier; +use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; +use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Support\TreeNode; use Kettasoft\Filterable\Traits\FieldNormalizer; -use Kettasoft\Filterable\Engines\Foundation\Engine; -use Kettasoft\Filterable\Engines\Foundation\ClauseApplier; -use Kettasoft\Filterable\Engines\Foundation\ClauseFactory; -use Kettasoft\Filterable\Engines\Foundation\Appliers\Applier; class Tree extends Engine { - use FieldNormalizer; + use FieldNormalizer; - /** - * Engine name. - * @var string - */ - protected $name = 'tree'; + /** + * Engine name. + * + * @var string + */ + protected $name = 'tree'; - /** - * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @return Builder - */ - public function execute(Builder $builder): Builder - { - $data = $this->context->getData(); + /** + * Apply filters to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * + * @return Builder + */ + public function execute(Builder $builder): Builder + { + $data = $this->context->getData(); - return $this->applyNode($builder, TreeNode::parse($data)); - } + return $this->applyNode($builder, TreeNode::parse($data)); + } - /** - * Apply tree node to the query builder. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @param \Kettasoft\Filterable\Support\TreeNode $node - * @return Builder - */ - private function applyNode(Builder $builder, TreeNode $node) - { - if ($node->isGroup()) { - $builder->where(function (Builder $query) use ($node) { - foreach ($node->children as $child) { - $this->attempt(function () use ($child, $query) { - $method = strtolower($child->logical) === 'and' ? 'where' : 'orWhere'; + /** + * Apply tree node to the query builder. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * @param \Kettasoft\Filterable\Support\TreeNode $node + * + * @return Builder + */ + private function applyNode(Builder $builder, TreeNode $node) + { + if ($node->isGroup()) { + $builder->where(function (Builder $query) use ($node) { + foreach ($node->children as $child) { + $this->attempt(function () use ($child, $query) { + $method = strtolower($child->logical) === 'and' ? 'where' : 'orWhere'; - $query->{$method}(function ($sub) use ($child) { - $this->applyNode($sub, $child); + $query->{$method}(function ($sub) use ($child) { + $this->applyNode($sub, $child); + }); + }); + } }); - }); - } - }); - } else { + } else { + $clause = (new ClauseFactory($this))->make( + new Payload($node->field, $node->operator ?? $this->defaultOperator(), $this->sanitizeValue($node->field, $node->value), $node->value) + ); - $clause = (new ClauseFactory($this))->make( - new Payload($node->field, $node->operator ?? $this->defaultOperator(), $this->sanitizeValue($node->field, $node->value), $node->value) - ); + if ($clause->isRelational()) { + $clause->relation($this->getResources()->relations)->resolve($builder, $clause); + } else { + Applier::apply(new ClauseApplier($clause), $builder); + } - if ($clause->isRelational()) { - $clause->relation($this->getResources()->relations)->resolve($builder, $clause); - } else { - Applier::apply(new ClauseApplier($clause), $builder); - } + $this->commit($node->field, $clause); + } - $this->commit($node->field, $clause); + return $builder; } - return $builder; - } - - /** - * Check if normalize field option is enable in engine. - * @return bool - */ - protected function hasNormalizeFieldCondition(): bool - { - return config('filterable.engines.tree.normalize_keys', false); - } + /** + * Check if normalize field option is enable in engine. + * + * @return bool + */ + protected function hasNormalizeFieldCondition(): bool + { + return config('filterable.engines.tree.normalize_keys', false); + } - /** - * Default operator for use. - * @return mixed|\Illuminate\Config\Repository - */ - public function defaultOperator() - { - return config('filterable.engines.tree.default_operator', null); - } + /** + * Default operator for use. + * + * @return mixed|\Illuminate\Config\Repository + */ + public function defaultOperator() + { + return config('filterable.engines.tree.default_operator', null); + } - /** - * Get engine name. - * @return string - */ - public function getEngineName(): string - { - return $this->name; - } + /** + * Get engine name. + * + * @return string + */ + public function getEngineName(): string + { + return $this->name; + } } diff --git a/src/Exceptions/Contracts/ExceptionHandlerInterface.php b/src/Exceptions/Contracts/ExceptionHandlerInterface.php index dacf695..fa1fe75 100644 --- a/src/Exceptions/Contracts/ExceptionHandlerInterface.php +++ b/src/Exceptions/Contracts/ExceptionHandlerInterface.php @@ -9,9 +9,11 @@ interface ExceptionHandlerInterface /** * Handle an exception that occurs during filtering. * - * @param \Throwable $exception The exception that was thrown. - * @param \Kettasoft\Filterable\Engines\Foundation\Engine $engine The engine in which the exception occurred. + * @param \Throwable $exception The exception that was thrown. + * @param \Kettasoft\Filterable\Engines\Foundation\Engine $engine The engine in which the exception occurred. + * * @throws \Throwable + * * @return bool */ public function handle(\Throwable $exception, Engine $engine): bool; diff --git a/src/Exceptions/FilterClassNotResolvedException.php b/src/Exceptions/FilterClassNotResolvedException.php index bc77291..54fdc34 100644 --- a/src/Exceptions/FilterClassNotResolvedException.php +++ b/src/Exceptions/FilterClassNotResolvedException.php @@ -4,8 +4,8 @@ class FilterClassNotResolvedException extends \InvalidArgumentException { - public function __construct(string $model) - { - parent::__construct("Could not resolve a filter class for model [{$model}]. Please either define a \$filterable property to the model or pass the filter class manually to the filter() method."); - } + public function __construct(string $model) + { + parent::__construct("Could not resolve a filter class for model [{$model}]. Please either define a \$filterable property to the model or pass the filter class manually to the filter() method."); + } } diff --git a/src/Exceptions/FilterIsNotDefinedException.php b/src/Exceptions/FilterIsNotDefinedException.php index f03b5b1..bf27033 100644 --- a/src/Exceptions/FilterIsNotDefinedException.php +++ b/src/Exceptions/FilterIsNotDefinedException.php @@ -4,15 +4,16 @@ class FilterIsNotDefinedException extends \Exception { - /** - * Create FilterIsNotDefinedException instance. - * @param mixed $filter - */ - public function __construct($filter) - { - parent::__construct(sprintf( - "Filter (%s) is not defined.", - $filter - )); - } + /** + * Create FilterIsNotDefinedException instance. + * + * @param mixed $filter + */ + public function __construct($filter) + { + parent::__construct(sprintf( + 'Filter (%s) is not defined.', + $filter + )); + } } diff --git a/src/Exceptions/FilterableExceptionHandler.php b/src/Exceptions/FilterableExceptionHandler.php index fe14fcc..8e84b42 100644 --- a/src/Exceptions/FilterableExceptionHandler.php +++ b/src/Exceptions/FilterableExceptionHandler.php @@ -15,6 +15,7 @@ abstract public function handle(\Throwable $exception, Engine $engine): bool; /** * Check if the strict mode is enable in config. + * * @return bool */ protected function isStrictThrowing(): bool @@ -24,7 +25,9 @@ protected function isStrictThrowing(): bool /** * Check if the exception is related to skipping filters. + * * @param \Throwable $exception + * * @return bool */ protected function hasSkipping($exception): bool @@ -34,7 +37,9 @@ protected function hasSkipping($exception): bool /** * Check if the exception is related to strictness. + * * @param \Throwable $exception + * * @return bool */ protected function isStrictness($exception): bool diff --git a/src/Exceptions/Handlers/DefaultHandler.php b/src/Exceptions/Handlers/DefaultHandler.php index 4a91d35..d1177dd 100644 --- a/src/Exceptions/Handlers/DefaultHandler.php +++ b/src/Exceptions/Handlers/DefaultHandler.php @@ -2,8 +2,8 @@ namespace Kettasoft\Filterable\Exceptions\Handlers; -use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; +use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Exceptions\FilterableExceptionHandler; /** @@ -13,13 +13,13 @@ class DefaultHandler extends FilterableExceptionHandler { /** * @inheritDoc - * @var SkipExecution $exception + * + * @var SkipExecution */ public function handle(\Throwable|SkipExecution $exception, Engine $engine): bool { if ($this->hasSkipping($exception)) { /** @var SkipExecution $exception */ - if ($engine->isStrict() || $this->isStrictThrowing()) { throw $exception; } diff --git a/src/Exceptions/MissingBuilderException.php b/src/Exceptions/MissingBuilderException.php index 8db0a60..a340141 100644 --- a/src/Exceptions/MissingBuilderException.php +++ b/src/Exceptions/MissingBuilderException.php @@ -4,11 +4,11 @@ class MissingBuilderException extends \RuntimeException { - /** - * MissingBuilderException constructor - */ - public function __construct() - { - parent::__construct("Filterable requires a valid Query Builder instance before applying filters"); - } + /** + * MissingBuilderException constructor. + */ + public function __construct() + { + parent::__construct('Filterable requires a valid Query Builder instance before applying filters'); + } } diff --git a/src/Exceptions/RequestSourceIsNotSupportedException.php b/src/Exceptions/RequestSourceIsNotSupportedException.php index a3efd83..2b0e12a 100644 --- a/src/Exceptions/RequestSourceIsNotSupportedException.php +++ b/src/Exceptions/RequestSourceIsNotSupportedException.php @@ -4,15 +4,16 @@ class RequestSourceIsNotSupportedException extends \InvalidArgumentException { - /** - * RequestSourceIsNotDefineException constructor. - * @param string $message - */ - public function __construct(string $source) - { - parent::__construct(sprintf( - "The request source (%s) is not supported, Allowed: query, input, json", - $source - )); - } + /** + * RequestSourceIsNotDefineException constructor. + * + * @param string $message + */ + public function __construct(string $source) + { + parent::__construct(sprintf( + 'The request source (%s) is not supported, Allowed: query, input, json', + $source + )); + } } diff --git a/src/Facades/Filterable.php b/src/Facades/Filterable.php index d92d750..f7ad9c7 100644 --- a/src/Facades/Filterable.php +++ b/src/Facades/Filterable.php @@ -6,114 +6,115 @@ /** * Facade for the Filterable class. - * + * * Static Factory Methods: + * * @method static \Kettasoft\Filterable\Filterable create(\Illuminate\Http\Request|null $request = null) Create new Filterable instance. - * @method static \Kettasoft\Filterable\Filterable withRequest(\Illuminate\Http\Request $request) Create new Filterable instance with custom Request. - * + * @method static \Kettasoft\Filterable\Filterable withRequest(\Illuminate\Http\Request $request) Create new Filterable instance with custom Request. + * * Static Event Methods: - * @method static void on(string $event, callable $callback) Register a global event listener. - * @method static void observe(string $filterClass, callable $callback) Register an observer for a specific filter class. - * @method static void flushListeners() Remove all registered event listeners and observers. - * @method static array getListeners(string $event) Get all registered listeners for a specific event. - * @method static array getObservers(string $filterClass) Get all registered observers for a specific filter class. - * @method static void resetEventManager() Reset the event manager instance. - * + * @method static void on(string $event, callable $callback) Register a global event listener. + * @method static void observe(string $filterClass, callable $callback) Register an observer for a specific filter class. + * @method static void flushListeners() Remove all registered event listeners and observers. + * @method static array getListeners(string $event) Get all registered listeners for a specific event. + * @method static array getObservers(string $filterClass) Get all registered observers for a specific filter class. + * @method static void resetEventManager() Reset the event manager instance. + * * Static Sorting Methods: - * @method static void addSorting(string|array $filterable, callable|string|\Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable $callback, \Illuminate\Http\Request|null $request = null) Add a sorting callback for a specific filterable. - * @method static \Kettasoft\Filterable\Foundation\Contracts\Sortable|null getSorting(string $filterClass) Get sorting rules for a Filterable class. - * @method static \Illuminate\Support\Collection aliases(array $aliases) Get all aliases. - * + * @method static void addSorting(string|array $filterable, callable|string|\Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable $callback, \Illuminate\Http\Request|null $request = null) Add a sorting callback for a specific filterable. + * @method static \Kettasoft\Filterable\Foundation\Contracts\Sortable|null getSorting(string $filterClass) Get sorting rules for a Filterable class. + * @method static \Illuminate\Support\Collection aliases(array $aliases) Get all aliases. + * * Core Filtering Methods: - * @method static \Kettasoft\Filterable\Foundation\Resources getResources() Get Resources instance. - * @method static \Kettasoft\Filterable\Foundation\FilterableSettings settings() Get FilterableSettings instance. - * @method static \Kettasoft\Filterable\Foundation\Invoker|\Illuminate\Contracts\Database\Eloquent\Builder apply(\Illuminate\Contracts\Database\Eloquent\Builder|null $builder = null) Apply all filters. - * @method static \Kettasoft\Filterable\Foundation\Invoker|\Illuminate\Contracts\Database\Eloquent\Builder filter(\Illuminate\Contracts\Database\Eloquent\Builder|null $builder = null) Alias name for apply method. - * @method static \Kettasoft\Filterable\Filterable sorting(callable|string|\Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable $sorting) Define sorting rules for the current filterable instance. - * @method static \Kettasoft\Filterable\Filterable shouldReturnQueryBuilder() Should return Query Builder instance when invoke apply. - * + * @method static \Kettasoft\Filterable\Foundation\Resources getResources() Get Resources instance. + * @method static \Kettasoft\Filterable\Foundation\FilterableSettings settings() Get FilterableSettings instance. + * @method static \Kettasoft\Filterable\Foundation\Invoker|\Illuminate\Contracts\Database\Eloquent\Builder apply(\Illuminate\Contracts\Database\Eloquent\Builder|null $builder = null) Apply all filters. + * @method static \Kettasoft\Filterable\Foundation\Invoker|\Illuminate\Contracts\Database\Eloquent\Builder filter(\Illuminate\Contracts\Database\Eloquent\Builder|null $builder = null) Alias name for apply method. + * @method static \Kettasoft\Filterable\Filterable sorting(callable|string|\Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable $sorting) Define sorting rules for the current filterable instance. + * @method static \Kettasoft\Filterable\Filterable shouldReturnQueryBuilder() Should return Query Builder instance when invoke apply. + * * Model Configuration: - * @method static \Kettasoft\Filterable\Filterable setModel(\Illuminate\Database\Eloquent\Model|string $model) Set model. - * @method static \Illuminate\Database\Eloquent\Model|string getModel() Get model. - * @method static \Illuminate\Database\Eloquent\Model|object|null getModelInstance() Get model instance object. - * + * @method static \Kettasoft\Filterable\Filterable setModel(\Illuminate\Database\Eloquent\Model|string $model) Set model. + * @method static \Illuminate\Database\Eloquent\Model|string getModel() Get model. + * @method static \Illuminate\Database\Eloquent\Model|object|null getModelInstance() Get model instance object. + * * Conditional & Pipeline Methods: * @method static \Kettasoft\Filterable\Filterable when(bool $condition, callable $callback) Apply a callback conditionally and return a new modified instance. - * @method static \Kettasoft\Filterable\Filterable through(array $pipes) Allow the query to pass through a custom pipeline of pipes (callables). - * + * @method static \Kettasoft\Filterable\Filterable through(array $pipes) Allow the query to pass through a custom pipeline of pipes (callables). + * * Engine Configuration: - * @method static \Kettasoft\Filterable\Filterable useEngine(\Kettasoft\Filterable\Engines\Foundation\Engine|string $engine) Override the default engine for this filterable instance. - * @method static \Kettasoft\Filterable\Engines\Foundation\Engine getEngine() Get current engine. - * + * @method static \Kettasoft\Filterable\Filterable useEngine(\Kettasoft\Filterable\Engines\Foundation\Engine|string $engine) Override the default engine for this filterable instance. + * @method static \Kettasoft\Filterable\Engines\Foundation\Engine getEngine() Get current engine. + * * Request & Data Management: - * @method static \Illuminate\Http\Request getRequest() Get the current request instance. - * @method static \Kettasoft\Filterable\Sanitization\Sanitizer getSanitizerInstance() Get sanitizer instance. - * @method static \Kettasoft\Filterable\Filterable setData(array $data, bool $override = true) Set manual data injection. - * @method static mixed getData() Get current data. - * @method static array getFilterAttributes() Fetch all relevant filters from the filter API class. - * @method static \Kettasoft\Filterable\Filterable setSource(string $source) Set request source. - * @method static mixed get(string $key) Retrieve an input item from the request. - * + * @method static \Illuminate\Http\Request getRequest() Get the current request instance. + * @method static \Kettasoft\Filterable\Sanitization\Sanitizer getSanitizerInstance() Get sanitizer instance. + * @method static \Kettasoft\Filterable\Filterable setData(array $data, bool $override = true) Set manual data injection. + * @method static mixed getData() Get current data. + * @method static array getFilterAttributes() Fetch all relevant filters from the filter API class. + * @method static \Kettasoft\Filterable\Filterable setSource(string $source) Set request source. + * @method static mixed get(string $key) Retrieve an input item from the request. + * * Sanitization: * @method static \Kettasoft\Filterable\Filterable setSanitizers(array $sanitizers, bool $override = true) Set a new sanitizers classes. - * @method static \Kettasoft\Filterable\Filterable withoutSanitizers() Disable running sanitizers on the filters. - * + * @method static \Kettasoft\Filterable\Filterable withoutSanitizers() Disable running sanitizers on the filters. + * * Value Processing: - * @method static \Kettasoft\Filterable\Filterable ignoreEmptyValues() Ignore empty or null values. - * @method static bool hasIgnoredEmptyValues() Check if current filterable class has ignored empty values. - * + * @method static \Kettasoft\Filterable\Filterable ignoreEmptyValues() Ignore empty or null values. + * @method static bool hasIgnoredEmptyValues() Check if current filterable class has ignored empty values. + * * Event Control: - * @method static \Kettasoft\Filterable\Filterable enableEvents() Enable events for this specific filterable instance. + * @method static \Kettasoft\Filterable\Filterable enableEvents() Enable events for this specific filterable instance. * @method static \Kettasoft\Filterable\Filterable disableEvents() Disable events for this specific filterable instance. - * + * * Caching Methods: - * @method static \Kettasoft\Filterable\Filterable cache(\DateTimeInterface|int|null $ttl = null) Enable caching with optional TTL. - * @method static \Kettasoft\Filterable\Filterable remember(\DateTimeInterface|int|null $ttl = null) Alias for cache() method. - * @method static \Kettasoft\Filterable\Filterable cacheForever() Cache results forever. - * @method static \Kettasoft\Filterable\Filterable cacheTags(array $tags) Set cache tags for tagging support. - * @method static \Kettasoft\Filterable\Filterable scopeByUser(int|string|null $userId = null) Scope cache by authenticated user. - * @method static \Kettasoft\Filterable\Filterable scopeByTenant(int|string $tenantId) Scope cache by tenant. - * @method static \Kettasoft\Filterable\Filterable scopeBy(string $key, mixed $value) Add a custom cache scope. - * @method static \Kettasoft\Filterable\Filterable withScopes(array $scopes) Set multiple cache scopes. - * @method static \Kettasoft\Filterable\Filterable cacheProfile(string $profile) Use a predefined cache profile. - * @method static \Kettasoft\Filterable\Filterable cacheWhen(bool|callable $condition, \DateTimeInterface|int|null $ttl = null) Cache only when condition is met. + * @method static \Kettasoft\Filterable\Filterable cache(\DateTimeInterface|int|null $ttl = null) Enable caching with optional TTL. + * @method static \Kettasoft\Filterable\Filterable remember(\DateTimeInterface|int|null $ttl = null) Alias for cache() method. + * @method static \Kettasoft\Filterable\Filterable cacheForever() Cache results forever. + * @method static \Kettasoft\Filterable\Filterable cacheTags(array $tags) Set cache tags for tagging support. + * @method static \Kettasoft\Filterable\Filterable scopeByUser(int|string|null $userId = null) Scope cache by authenticated user. + * @method static \Kettasoft\Filterable\Filterable scopeByTenant(int|string $tenantId) Scope cache by tenant. + * @method static \Kettasoft\Filterable\Filterable scopeBy(string $key, mixed $value) Add a custom cache scope. + * @method static \Kettasoft\Filterable\Filterable withScopes(array $scopes) Set multiple cache scopes. + * @method static \Kettasoft\Filterable\Filterable cacheProfile(string $profile) Use a predefined cache profile. + * @method static \Kettasoft\Filterable\Filterable cacheWhen(bool|callable $condition, \DateTimeInterface|int|null $ttl = null) Cache only when condition is met. * @method static \Kettasoft\Filterable\Filterable cacheUnless(bool|callable $condition, \DateTimeInterface|int|null $ttl = null) Cache unless condition is met. - * @method static bool flushCache() Flush cache for this filter instance. - * @method static bool flushCacheByTags(array|null $tags = null) Flush cache entries by tags. - * @method static bool isCachingEnabled() Check if caching is enabled. - * @method static \DateTimeInterface|int|null getCacheTtl() Get cache TTL. - * @method static array getCacheTags() Get cache tags. - * @method static array getCacheScopes() Get cache scopes. - * @method static string|null getCacheProfile() Get cache profile. - * + * @method static bool flushCache() Flush cache for this filter instance. + * @method static bool flushCacheByTags(array|null $tags = null) Flush cache entries by tags. + * @method static bool isCachingEnabled() Check if caching is enabled. + * @method static \DateTimeInterface|int|null getCacheTtl() Get cache TTL. + * @method static array getCacheTags() Get cache tags. + * @method static array getCacheScopes() Get cache scopes. + * @method static string|null getCacheProfile() Get cache profile. + * * Header-Driven Mode: * @method static \Kettasoft\Filterable\Filterable withHeaderDrivenMode(mixed $config = []) Enable Header-driven mode per request. - * + * * Field Configuration: - * @method static array getAllowedFields() Get allowed fields to apply filtering. + * @method static array getAllowedFields() Get allowed fields to apply filtering. * @method static \Kettasoft\Filterable\Filterable setAllowedFields(array $fields, bool $override = false) Define allowed fields to filtering. - * @method static \Kettasoft\Filterable\Filterable autoSetAllowedFieldsFromModel(bool $override = false) Auto-detect filterable fields from model fillable attributes. - * + * @method static \Kettasoft\Filterable\Filterable autoSetAllowedFieldsFromModel(bool $override = false) Auto-detect filterable fields from model fillable attributes. + * * Operator Configuration: - * @method static array getAllowedOperators() List of supported SQL operators you want to allow when parsing the expressions. + * @method static array getAllowedOperators() List of supported SQL operators you want to allow when parsing the expressions. * @method static \Kettasoft\Filterable\Filterable allowedOperators(array $operators) Set allowed operators and override global operators. - * + * * Mode Configuration: - * @method static \Kettasoft\Filterable\Filterable strict() Enable strict mode in this instance. + * @method static \Kettasoft\Filterable\Filterable strict() Enable strict mode in this instance. * @method static \Kettasoft\Filterable\Filterable permissive() Enable permissive mode in this instance. - * @method static mixed isStrict() Check if filter has strict mode. - * + * @method static mixed isStrict() Check if filter has strict mode. + * * Field Mapping: - * @method static array getFieldsMap() Get columns wrapper. + * @method static array getFieldsMap() Get columns wrapper. * @method static \Kettasoft\Filterable\Filterable setFieldsMap(mixed $fields, bool $override = true) Set fields wrapper. - * + * * Builder Management: - * @method static \Illuminate\Contracts\Database\Eloquent\Builder getBuilder() Get registered filter builder. - * @method static \Kettasoft\Filterable\Filterable setBuilder(\Illuminate\Contracts\Database\Eloquent\Builder $builder) Set a new builder. - * + * @method static \Illuminate\Contracts\Database\Eloquent\Builder getBuilder() Get registered filter builder. + * @method static \Kettasoft\Filterable\Filterable setBuilder(\Illuminate\Contracts\Database\Eloquent\Builder $builder) Set a new builder. + * * SQL Export: * @method static string toSql(\Illuminate\Contracts\Database\Eloquent\Builder|null $builder = null, mixed $withBindings = false) Get the SQL representation of the filtered query. - * + * * @see \Kettasoft\Filterable\Filterable */ class Filterable extends Facade diff --git a/src/Filterable.php b/src/Filterable.php index b90903d..5017908 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -2,1001 +2,1121 @@ namespace Kettasoft\Filterable; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Facades\App; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Traits\Macroable; -use Kettasoft\Filterable\Foundation\Invoker; +use Kettasoft\Filterable\Contracts\Authorizable; use Kettasoft\Filterable\Contracts\Commitable; -use Kettasoft\Filterable\Foundation\Resources; +use Kettasoft\Filterable\Contracts\FilterableContext; use Kettasoft\Filterable\Contracts\Validatable; -use Kettasoft\Filterable\Contracts\Authorizable; -use Kettasoft\Filterable\Sanitization\Sanitizer; -use Illuminate\Contracts\Database\Eloquent\Builder; +use Kettasoft\Filterable\Engines\Factory\EngineManager; use Kettasoft\Filterable\Engines\Foundation\Clause; use Kettasoft\Filterable\Engines\Foundation\Engine; -use Kettasoft\Filterable\Foundation\Sorting\Sorter; -use Kettasoft\Filterable\Contracts\FilterableContext; -use Kettasoft\Filterable\Engines\Factory\EngineManager; -use Kettasoft\Filterable\Foundation\Contracts\Sortable; -use Kettasoft\Filterable\Foundation\FilterableSettings; -use Kettasoft\Filterable\Exceptions\MissingBuilderException; -use Kettasoft\Filterable\Foundation\Traits\HandleFluentReturn; use Kettasoft\Filterable\Engines\Foundation\Executors\Executer; +use Kettasoft\Filterable\Exceptions\Contracts\ExceptionHandlerInterface; +use Kettasoft\Filterable\Exceptions\MissingBuilderException; +use Kettasoft\Filterable\Exceptions\RequestSourceIsNotSupportedException; use Kettasoft\Filterable\Foundation\Contracts\FilterableProfile; +use Kettasoft\Filterable\Foundation\Contracts\ShouldReturnQueryBuilder; +use Kettasoft\Filterable\Foundation\Contracts\Sortable; use Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable; use Kettasoft\Filterable\Foundation\Events\Contracts\EventManager; use Kettasoft\Filterable\Foundation\Events\FilterableEventManager; +use Kettasoft\Filterable\Foundation\FilterableSettings; +use Kettasoft\Filterable\Foundation\Invoker; +use Kettasoft\Filterable\Foundation\Resources; +use Kettasoft\Filterable\Foundation\Sorting\Sorter; +use Kettasoft\Filterable\Foundation\Traits\HandleFluentReturn; use Kettasoft\Filterable\HttpIntegration\HeaderDrivenEngineSelector; -use Kettasoft\Filterable\Foundation\Contracts\ShouldReturnQueryBuilder; -use Kettasoft\Filterable\Exceptions\Contracts\ExceptionHandlerInterface; -use Kettasoft\Filterable\Exceptions\RequestSourceIsNotSupportedException; +use Kettasoft\Filterable\Sanitization\Sanitizer; class Filterable implements FilterableContext, Authorizable, Validatable, Commitable { - use Traits\InteractsWithFilterKey, - Traits\InteractsWithMethodMentoring, - Traits\InteractsWithFilterAuthorization, - Traits\InteractsWithValidation, - Traits\InteractsWithRelationsFiltering, - Traits\HasFilterableEvents, - Traits\InteractsWithProvidedData, - Traits\HasFilterableCache, - HandleFluentReturn, - Macroable; - - /** - * The running filter engine. - * @var Engine - */ - protected Engine $engine; - - /** - * Resources instance. - * @var Resources - */ - protected Resources $resources; - - /** - * Registered filters to operate upon. - * @var array - */ - protected $filters = []; - - /** - * Ignore empty or null values option. - * @var bool - */ - protected $ignoreEmptyValues; - - /** - * The Request instance. - * @var Request - */ - protected Request $request; - - /** - * Request source. - * @var string|null - */ - protected $requestSource = 'query'; - - /** - * The Builder instance. - * @var \Illuminate\Database\Eloquent\Builder - */ - protected Builder $builder; - - /** - * Registered sanitizers to operate upon. - * @var array - */ - protected $sanitizers = []; - - /** - * All received data from request. - * @var array - */ - protected $data = []; - - /** - * Specify which fields are allowed to be filtered. - * @var array - */ - protected $allowedFields = []; - - /** - * List of supported SQL operators you want to allow when parsing the expressions. - * @var array - */ - protected $allowedOperators = []; - - /** - * Strict mode. - * @var bool|null - */ - protected $strict; - - /** - * The field name mapping. - * @var array - */ - protected $fieldsMap = []; - - /** - * Registered model. - * @var Model - */ - protected $model; - - /** - * Aliases of filter class - * @var array - */ - protected static $aliases; - - /** - * The Sanitizer instance. - * @var Sanitizer - */ - public Sanitizer $sanitizer; - - /** - * Sorters for each filterable. - * @var array> - */ - protected static array $sorters = []; - - /** - * @var bool - */ - protected $shouldReturnQueryBuilder = false; - - /** - * Event manager instance. - * @var EventManager - */ - protected static EventManager $eventManager; - - /** - * Applied clauses. - * @var array - */ - protected $applied = []; - - /** - * Create a new Filterable instance. - * @param Request|null $request - */ - public function __construct(Request|null $request = null) - { - $this->boot($request); - $this->booting(); - $this->booted(); - } - - /** - * Initialize core dependencies and fire the initializing event. - * - * @return void - */ - public function boot($request = null) - { - $this->request = $request ?: App::make(Request::class); - $this->registerEventManager(); - - // Fire initializing event - $this->fireEvent('filterable.initializing', ['filterable' => $this]); - } - - /** - * Prepare engine and internal components. - * - * @return void - */ - public function booting() - { - $this->sanitizer = new Sanitizer($this->sanitizers); - $this->resources = new Resources($this->settings()); - $this->resolveEngine(); - $this->parseIncomingRequestData(); - } - - /** - * Finalize setup and fire the booted event. - * - * @return void - */ - public function booted() - { - // Fire resolved event after initialization is complete - $this->fireEvent('filterable.resolved', [ - 'engine' => $this->engine, - 'data' => $this->data, - ]); - } - - /** - * Get request source. - * - * @return string - */ - public function getRequestSource(): string - { - return $this->requestSource; - } - - /** - * Apply a filterable profile to the current instance. - * - * @param FilterableProfile|string $profile - * @return static - */ - public function useProfile(FilterableProfile|callable|string $profile): static - { - // Handle callable or FilterableProfile instance directly - if (is_callable($profile)) { - $profile($this); - return $this; - } - - if ($profile instanceof FilterableProfile) { - $profile($this); - return $this; - } - - // Handle string references (class name or config key) - if (is_string($profile)) { - $profiles = config('filterable.profiles', []); - $resolved = $profiles[$profile] ?? $profile; - - // If still not found or invalid, return as-is - if (!class_exists($resolved)) { + use Traits\InteractsWithFilterKey; + use Traits\InteractsWithMethodMentoring; + use Traits\InteractsWithFilterAuthorization; + use Traits\InteractsWithValidation; + use Traits\InteractsWithRelationsFiltering; + use Traits\HasFilterableEvents; + use Traits\InteractsWithProvidedData; + use Traits\HasFilterableCache; + use HandleFluentReturn; + use Macroable; + + /** + * The running filter engine. + * + * @var Engine + */ + protected Engine $engine; + + /** + * Resources instance. + * + * @var Resources + */ + protected Resources $resources; + + /** + * Registered filters to operate upon. + * + * @var array + */ + protected $filters = []; + + /** + * Ignore empty or null values option. + * + * @var bool + */ + protected $ignoreEmptyValues; + + /** + * The Request instance. + * + * @var Request + */ + protected Request $request; + + /** + * Request source. + * + * @var string|null + */ + protected $requestSource = 'query'; + + /** + * The Builder instance. + * + * @var \Illuminate\Database\Eloquent\Builder + */ + protected Builder $builder; + + /** + * Registered sanitizers to operate upon. + * + * @var array + */ + protected $sanitizers = []; + + /** + * All received data from request. + * + * @var array + */ + protected $data = []; + + /** + * Specify which fields are allowed to be filtered. + * + * @var array + */ + protected $allowedFields = []; + + /** + * List of supported SQL operators you want to allow when parsing the expressions. + * + * @var array + */ + protected $allowedOperators = []; + + /** + * Strict mode. + * + * @var bool|null + */ + protected $strict; + + /** + * The field name mapping. + * + * @var array + */ + protected $fieldsMap = []; + + /** + * Registered model. + * + * @var Model + */ + protected $model; + + /** + * Aliases of filter class. + * + * @var array + */ + protected static $aliases; + + /** + * The Sanitizer instance. + * + * @var Sanitizer + */ + public Sanitizer $sanitizer; + + /** + * Sorters for each filterable. + * + * @var array> + */ + protected static array $sorters = []; + + /** + * @var bool + */ + protected $shouldReturnQueryBuilder = false; + + /** + * Event manager instance. + * + * @var EventManager + */ + protected static EventManager $eventManager; + + /** + * Applied clauses. + * + * @var array + */ + protected $applied = []; + + /** + * Create a new Filterable instance. + * + * @param Request|null $request + */ + public function __construct(?Request $request = null) + { + $this->boot($request); + $this->booting(); + $this->booted(); + } + + /** + * Initialize core dependencies and fire the initializing event. + * + * @return void + */ + public function boot($request = null) + { + $this->request = $request ?: App::make(Request::class); + $this->registerEventManager(); + + // Fire initializing event + $this->fireEvent('filterable.initializing', ['filterable' => $this]); + } + + /** + * Prepare engine and internal components. + * + * @return void + */ + public function booting() + { + $this->sanitizer = new Sanitizer($this->sanitizers); + $this->resources = new Resources($this->settings()); + $this->resolveEngine(); + $this->parseIncomingRequestData(); + } + + /** + * Finalize setup and fire the booted event. + * + * @return void + */ + public function booted() + { + // Fire resolved event after initialization is complete + $this->fireEvent('filterable.resolved', [ + 'engine' => $this->engine, + 'data' => $this->data, + ]); + } + + /** + * Get request source. + * + * @return string + */ + public function getRequestSource(): string + { + return $this->requestSource; + } + + /** + * Apply a filterable profile to the current instance. + * + * @param FilterableProfile|string $profile + * + * @return static + */ + public function useProfile(FilterableProfile|callable|string $profile): static + { + // Handle callable or FilterableProfile instance directly + if (is_callable($profile)) { + $profile($this); + + return $this; + } + + if ($profile instanceof FilterableProfile) { + $profile($this); + + return $this; + } + + // Handle string references (class name or config key) + if (is_string($profile)) { + $profiles = config('filterable.profiles', []); + $resolved = $profiles[$profile] ?? $profile; + + // If still not found or invalid, return as-is + if (!class_exists($resolved)) { + return $this; + } + + $instance = App::make($resolved); + + if (is_callable($instance)) { + $instance($this); + } + + return $this; + } + return $this; - } - - $instance = App::make($resolved); - - if (is_callable($instance)) { - $instance($this); - } - - return $this; - } - - return $this; - } - - /** - * Commit clause. - * - * @param string $key - * @param Clause $clause - * @return bool - */ - public function commit(string $key, Clause $clause): bool - { - $this->applied[$key] = $clause; - return true; - } - - /** - * Get applied clauses. - * - * @param string $key - * @return array|Clause|null - */ - public function applied($key = null) - { - if (!$key) { - return $this->applied; - } - - return $this->applied[$key] ?? null; - } - - /** - * Register the event manager instance. - * @param array $options - * @return void - */ - private function registerEventManager(array $options = []) - { - self::$eventManager = App::make(FilterableEventManager::class, $options); - } - - /** - * Get Resources instance. - * @return Resources - */ - public function getResources(): Resources - { - return $this->resources; - } - - /** - * Get FilterableSettings instance. - * @return FilterableSettings - */ - public function settings(): FilterableSettings - { - return FilterableSettings::init( - $this->allowedFields, - $this->relations, - $this->allowedOperators, - $this->sanitizers, - $this->fieldsMap - ); - } - - /** - * Apply all filters. - * - * @param Builder $builder - * @return Builder|Invoker - */ - public function apply(Builder|null $builder = null): Invoker|Builder - { - try { - App::make(Pipeline::class)->send($this)->through([ - \Kettasoft\Filterable\Pipes\FilterAuthorizationPipe::class, - \Kettasoft\Filterable\Pipes\ValidateBeforeFilteringPipe::class - ])->thenReturn(); - - $builder = $this->initQueryBuilderInstance($builder); - - $this->builder = $this->initially($builder); - - $builder = Executer::execute($this->engine, $builder); - - if (isset(self::$sorters[static::class])) { - $builder = static::getSorting(static::class)?->apply($builder); - } - - // Fire applied event on success - $this->fireEvent('filterable.applied', [ - 'filterable' => $this - ]); - - if ($this instanceof ShouldReturnQueryBuilder || $this->shouldReturnQueryBuilder) { - return $this->finally($builder); - } - - $invoker = new Invoker($this->finally($builder)); - - // Pass caching settings to invoker - if ($this->isCachingEnabled()) { - $invoker->enableCaching( - $this->generateCacheKey(), - $this->getCacheTtl(), - $this->getCacheTags(), - $this->cacheForever + } + + /** + * Commit clause. + * + * @param string $key + * @param Clause $clause + * + * @return bool + */ + public function commit(string $key, Clause $clause): bool + { + $this->applied[$key] = $clause; + + return true; + } + + /** + * Get applied clauses. + * + * @param string $key + * + * @return array|Clause|null + */ + public function applied($key = null) + { + if (!$key) { + return $this->applied; + } + + return $this->applied[$key] ?? null; + } + + /** + * Register the event manager instance. + * + * @param array $options + * + * @return void + */ + private function registerEventManager(array $options = []) + { + self::$eventManager = App::make(FilterableEventManager::class, $options); + } + + /** + * Get Resources instance. + * + * @return Resources + */ + public function getResources(): Resources + { + return $this->resources; + } + + /** + * Get FilterableSettings instance. + * + * @return FilterableSettings + */ + public function settings(): FilterableSettings + { + return FilterableSettings::init( + $this->allowedFields, + $this->relations, + $this->allowedOperators, + $this->sanitizers, + $this->fieldsMap ); - } - - return $invoker; - } catch (\Throwable $exception) { - // Fire failed event on exception - $this->fireEvent('filterable.failed', [ - 'exception' => $exception, - 'filterable' => $this, - ]); - - // Re-throw the exception after firing the event - throw $exception; - } finally { - // Always fire finished event - $this->fireEvent('filterable.finished', [ - 'filterable' => $this, - ]); - } - } - - /** - * Finalize the query builder after all filters have been applied. - * - * @param Builder $builder - * @return Builder - */ - protected function finally(Builder $builder): Builder - { - // Custom finalization logic can be added here - return $builder; - } - - /** - * Initial processing of the query builder before applying filters. - * - * @param Builder $builder - * @return Builder - */ - protected function initially(Builder $builder): Builder - { - // Custom initial logic can be added here - return $builder; - } - - /** - * Create and return a new Filterable instance after applying the given callback. - * - * @param callable \Closure(static): void $callback A callback that receives the instance for configuration. - * @return static - */ - public static function tap(callable $callback, $instance = null): static - { - $instance = $instance ?: new static; - $callback($instance); - return $instance; - } - - /** - * Add a sorting callback for a specific filterable. - * - * @param string|array $filterable - * @param callable $callback - * @return void - */ - public static function addSorting(string|array $filterable, callable|string|Invokable $callback, Request|null $request = null): void - { - if (is_string($filterable)) { - $filterable = [$filterable]; - } - - foreach ($filterable as $filter) { - if (is_string($callback) && class_exists($callback) && is_subclass_of($callback, Invokable::class)) { - $callback = app($callback, ['request' => $request ?: app('request')]); - return; - } - - if (!is_callable($callback) && !$callback instanceof Invokable) { - throw new \InvalidArgumentException('The sorting callback must be a callable or an instance of ' . Invokable::class); - } - - $request = $request ?: app('request'); - - self::$sorters[$filter] = $callback(new Sorter($request), $request); - } - } - - /** - * Define sorting rules for the current filterable instance. - * - * @param callable $sorting - * @return static - */ - public function sorting(callable|string|Invokable $sorting): static - { - static::addSorting(static::class, $sorting); - return $this; - } - - /** - * Get sorting rules for a Filterable class. - * - * @param string $filterClass - * @return Sortable|null - */ - public static function getSorting(string $filterClass): ?Sortable - { - return static::$sorters[$filterClass] ?? null; - } - - /** - * Should return Query Builder instance when invoke `@apply` - * @return static - */ - public function shouldReturnQueryBuilder() - { - $this->shouldReturnQueryBuilder = true; - - return $this; - } - - /** - * Alias name for @apply method. - * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder - * @return Invoker|Builder - */ - public function filter(Builder|null $builder = null): Invoker|Builder - { - return $this->apply($builder); - } - - /** - * Get all aliases. - * @return array - */ - public static function aliases(array $aliases) - { - self::$aliases = $aliases; - - return self::$aliases; - } - - /** - * Initialize query builder instance. - * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder - * @throws \Kettasoft\Filterable\Exceptions\MissingBuilderException - */ - private function initQueryBuilderInstance(Builder|null $builder = null) - { - if ($builder) - return $builder; - - if (isset($this->builder)) - return $this->builder; - - if ($this->model instanceof Model) { - return $this->model->query(); - } - - if (is_a($this->model, Model::class, true)) { - return $this->model::query(); - } - - throw new MissingBuilderException; - } - - /** - * Set model. - * @param \Illuminate\Database\Eloquent\Model|string $model - * @return static - */ - public function setModel(Model|string $model): static - { - $this->model = $model; - return $this; - } - - /** - * Get model. - * @return Model|string - */ - public function getModel() - { - return $this->model; - } - - /** - * Get model instance object. - * @return Model|object|null - */ - public function getModelInstance() - { - if ($this->model instanceof Model) { - return $this->model; - } - - if (is_a($this->model, Model::class, true)) { - return new $this->model; - } - - return null; - } - - /** - * Create new Filterable instance. - * @param \Illuminate\Http\Request|null $request - * @return static - */ - public static function create(Request|null $request = null): static - { - return new static($request ?? App::make(Request::class)); - } - - /** - * Apply a callback conditionally and return a new modified instance. - * @param bool $condition - * @param callable(static): void $callback - * @return static - * @link https://kettasoft.github.io/filterable/features/conditional-logic - */ - public function when(bool $condition, callable $callback) - { - if ($condition) { - call_user_func($callback, $this); - } - - return $this; - } - - /** - * Inverse of `when` method. - * @param bool $condition - * @param callable(static): void $callback - * @return static - * @link https://kettasoft.github.io/filterable/features/conditional-logic - */ - public function unless(bool $condition, callable $callback) - { - return $this->when(!$condition, $callback); - } - - /** - * Allow the query to pass through a custom pipeline of pipes (callables). - * - * @param array $pipes - * @return static - * @link https://kettasoft.github.io/filterable/features/through - */ - public function through(array $pipes): static - { - foreach ($pipes as $pipe) { - if (!is_callable($pipe)) { - throw new \InvalidArgumentException('All pipes passed to `through` must be callable.'); - } - - $pipe($this->builder, $this); - } - - return $this; - } - - /** - * Override the default engine for this filterable instance. - * @param \Kettasoft\Filterable\Engines\Foundation\Engine|string $engine - * @return Filterable - */ - public function useEngine(Engine|string $engine): static - { - $this->engine = EngineManager::generate($engine, $this); - - return $this; - } - - /** - * Get current engine. - * @return Engine - */ - public function getEngine(): Engine - { - return $this->engine; - } - - /** - * Get the current request instance. - * @return Request - */ - public function getRequest(): Request - { - return $this->request; - } - - /** - * Get sanitizer instance. - * @return Sanitizer - */ - public function getSanitizerInstance(): Sanitizer - { - return $this->sanitizer; - } - - /** - * Set manual data injection. - * @param array $data - * @param bool $override - * @return static - */ - public function setData(array $data, bool $override = true): static - { - $this->data = $override ? $data : array_merge($this->data, $data); - return $this; - } - - /** - * Create new Filterable instance with custom Request. - * @param \Illuminate\Http\Request $request - * @return static - */ - public static function withRequest(Request $request): static - { - return static::create($request); - } - - /** - * Set a new sanitizers classes. - * @param array $sanitizers - * @return Filterable - */ - public function setSanitizers(array $sanitizers, bool $override = true): static - { - $this->sanitizers = $override ? $sanitizers : array_merge($this->sanitizers, $sanitizers); - $this->sanitizer->setSanitizers($this->sanitizers); - return $this; - } - - /** - * Disable running sanitizers on the filters. - * @return static - */ - public function withoutSanitizers(): static - { - $this->sanitizers = []; - $this->sanitizer->setSanitizers([]); - - return $this; - } - - /** - * Parse incomming data from request. - * @return void - */ - private function parseIncomingRequestData() - { - $this->data = [...$this->request->all(), ...$this->request->json()->all()]; - } - - /** - * Get current data. - * @return array - */ - public function getData(): mixed - { - return $this->filterKey === null ? $this->data : $this->data[$this->filterKey] ?? $this->data; - } - - /** - * Fetch all relevant filters from the filter API class. - * - * @return array - */ - public function getFilterAttributes(): array - { - return property_exists($this, 'filters') - && is_array($this->filters) ? $this->filters : []; - } - - /** - * Resolve default engine to Filterable instance. - * @return void - */ - private function resolveEngine() - { - $this->useEngine((new HeaderDrivenEngineSelector($this->request))->resolve()); - } - - /** - * Set request source. - * @param string $source - * @throws \Kettasoft\Filterable\Exceptions\RequestSourceIsNotSupportedException - * @return static - */ - public function setSource(string $source) - { - if (!in_array($source, ['query', 'input', 'json'])) { - throw new RequestSourceIsNotSupportedException($source); - } - - $this->requestSource = $source; - return $this; - } - - /** - * Ignore empty or null values. - * @return Filterable - */ - public function ignoreEmptyValues(): static - { - $this->ignoreEmptyValues = true; - return $this; - } - - /** - * Check if current filterable class has ignored empty values. - * @return bool - */ - public function hasIgnoredEmptyValues(): bool - { - return $this->ignoreEmptyValues === true; - } - - /** - * Enable Header-driven mode per request. - * @param mixed $config - * @return Filterable - */ - public function withHeaderDrivenMode($config = []): static - { - return $this->useEngine((new HeaderDrivenEngineSelector($this->request, array_merge( - config('filterable.header_driven_mode', []), - ['enabled' => true], - $config - )))->resolve()); - } - - /** - * Get allowed fields to apply filtering. - * @return array - */ - public function getAllowedFields(): array - { - return $this->allowedFields; - } - - /** - * List of supported SQL operators you want to allow when parsing the expressions. - * @return array - */ - public function getAllowedOperators(): array - { - return $this->allowedOperators; - } - - /** - * Set allowed operators and override global operators. - * @param array $operators - * @return static - */ - public function allowedOperators(array $operators): static - { - $this->allowedOperators = $operators; - return $this; - } - - /** - * Define allowed fields to filtering. - * @param array $fields - * @return Filterable - */ - public function setAllowedFields(array $fields, bool $override = false): static - { - $this->allowedFields = $override ? $fields : array_merge($this->allowedFields, $fields); - $this->resources->fields->fill($this->allowedFields); - return $this; - } - - /** - * Enable strict mode in this instance. - * @return Filterable - */ - public function strict(): static - { - $this->strict = true; - return $this; - } - - /** - * Enable strict mode in this instance. - * @return Filterable - */ - public function permissive(): static - { - $this->strict = false; - return $this; - } - - /** - * Check if filter has strict mode. - * @return mixed - */ - public function isStrict() - { - if (is_bool($this->strict)) { - return $this->strict; - } - - return null; - } - - /** - * Get columns wrapper. - * @return array - */ - public function getFieldsMap(): array - { - return $this->fieldsMap; - } - - /** - * Set fields wrapper. - * @param array $fields - * @param bool $override - * @return static - */ - public function setFieldsMap($fields, bool $override = true): static - { - $this->fieldsMap = $override ? $fields : array_merge($this->fieldsMap, $fields); - return $this; - } - - /** - * Get registered filter builder. - * @return Builder - */ - public function getBuilder(): Builder - { - return $this->builder; - } - - /** - * Set a new builder. - * @param Builder $builder - * @return static - */ - public function setBuilder(Builder $builder): static - { - $this->builder = $builder; - return $this; - } - - /** - * Auto-detect filterable fields from model fillable attributes. - * @param bool $override To override current fields - * @return static - */ - public function autoSetAllowedFieldsFromModel(bool $override = false): static - { - $fillable = $this->builder->getModel()->getFillable(); - $this->allowedFields = $override ? $fillable : array_merge($this->allowedFields, $fillable); - - return $this; - } - - /** - * Get the SQL representation of the filtered query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder - * @param mixed $withBindings - * @return string - */ - public function toSql(Builder|null $builder = null, $withBindings = false): string - { - $builder = $this->apply($builder ?? $this->builder); - - return $withBindings ? $builder->toRawSql() : $builder->toSql(); - } - - /** - * Retrieve an input item from the request. - * @param string $key - * @return mixed - */ - public function get(string $key) - { - if (!in_array($source = $this->requestSource ?? config('filterable.request_source', 'query'), ['query', 'input', 'json'])) { - throw new RequestSourceIsNotSupportedException($source); - } - - return $this->request->{$source}($key); - } - - /** - * Get exception handler instance. - * - * @return ExceptionHandlerInterface - */ - public function getExceptionHandler(): ExceptionHandlerInterface - { - $config = config('filterable.exceptions'); - $engineOverrides = config("filterable.engines.{$this->engine->getEngineName()}.exceptions", []); - - $merged = array_merge($config, $engineOverrides); - - return app($merged['handler']); - } - - /** - * Dynamically retrieve attributes from the request. - * @param mixed $property - * @return mixed - */ - public function __get($property): mixed - { - if (property_exists($this, $property)) { - return $this->{$property}; - } - - return $this->get($property); - } - - /** - * Handle dynamic method calls to the builder. - * @param string $method - * @param array $parameters - * @return mixed - */ - public function __call($method, $parameters) - { - return $this->handleFluentReturn($method, $parameters); - } + } + + /** + * Apply all filters. + * + * @param Builder $builder + * + * @return Builder|Invoker + */ + public function apply(?Builder $builder = null): Invoker|Builder + { + try { + App::make(Pipeline::class)->send($this)->through([ + \Kettasoft\Filterable\Pipes\FilterAuthorizationPipe::class, + \Kettasoft\Filterable\Pipes\ValidateBeforeFilteringPipe::class, + ])->thenReturn(); + + $builder = $this->initQueryBuilderInstance($builder); + + $this->builder = $this->initially($builder); + + $builder = Executer::execute($this->engine, $builder); + + if (isset(self::$sorters[static::class])) { + $builder = static::getSorting(static::class)?->apply($builder); + } + + // Fire applied event on success + $this->fireEvent('filterable.applied', [ + 'filterable' => $this, + ]); + + if ($this instanceof ShouldReturnQueryBuilder || $this->shouldReturnQueryBuilder) { + return $this->finally($builder); + } + + $invoker = new Invoker($this->finally($builder)); + + // Pass caching settings to invoker + if ($this->isCachingEnabled()) { + $invoker->enableCaching( + $this->generateCacheKey(), + $this->getCacheTtl(), + $this->getCacheTags(), + $this->cacheForever + ); + } + + return $invoker; + } catch (\Throwable $exception) { + // Fire failed event on exception + $this->fireEvent('filterable.failed', [ + 'exception' => $exception, + 'filterable' => $this, + ]); + + // Re-throw the exception after firing the event + throw $exception; + } finally { + // Always fire finished event + $this->fireEvent('filterable.finished', [ + 'filterable' => $this, + ]); + } + } + + /** + * Finalize the query builder after all filters have been applied. + * + * @param Builder $builder + * + * @return Builder + */ + protected function finally(Builder $builder): Builder + { + // Custom finalization logic can be added here + return $builder; + } + + /** + * Initial processing of the query builder before applying filters. + * + * @param Builder $builder + * + * @return Builder + */ + protected function initially(Builder $builder): Builder + { + // Custom initial logic can be added here + return $builder; + } + + /** + * Create and return a new Filterable instance after applying the given callback. + * + * @param callable \Closure(static): void $callback A callback that receives the instance for configuration. + * + * @return static + */ + public static function tap(callable $callback, $instance = null): static + { + $instance = $instance ?: new static(); + $callback($instance); + + return $instance; + } + + /** + * Add a sorting callback for a specific filterable. + * + * @param string|array $filterable + * @param callable $callback + * + * @return void + */ + public static function addSorting(string|array $filterable, callable|string|Invokable $callback, ?Request $request = null): void + { + if (is_string($filterable)) { + $filterable = [$filterable]; + } + + foreach ($filterable as $filter) { + if (is_string($callback) && class_exists($callback) && is_subclass_of($callback, Invokable::class)) { + $callback = app($callback, ['request' => $request ?: app('request')]); + + return; + } + + if (!is_callable($callback) && !$callback instanceof Invokable) { + throw new \InvalidArgumentException('The sorting callback must be a callable or an instance of '.Invokable::class); + } + + $request = $request ?: app('request'); + + self::$sorters[$filter] = $callback(new Sorter($request), $request); + } + } + + /** + * Define sorting rules for the current filterable instance. + * + * @param callable $sorting + * + * @return static + */ + public function sorting(callable|string|Invokable $sorting): static + { + static::addSorting(static::class, $sorting); + + return $this; + } + + /** + * Get sorting rules for a Filterable class. + * + * @param string $filterClass + * + * @return Sortable|null + */ + public static function getSorting(string $filterClass): ?Sortable + { + return static::$sorters[$filterClass] ?? null; + } + + /** + * Should return Query Builder instance when invoke `@apply`. + * + * @return static + */ + public function shouldReturnQueryBuilder() + { + $this->shouldReturnQueryBuilder = true; + + return $this; + } + + /** + * Alias name for @apply method. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder + * + * @return Invoker|Builder + */ + public function filter(?Builder $builder = null): Invoker|Builder + { + return $this->apply($builder); + } + + /** + * Get all aliases. + * + * @return array + */ + public static function aliases(array $aliases) + { + self::$aliases = $aliases; + + return self::$aliases; + } + + /** + * Initialize query builder instance. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder + * + * @throws \Kettasoft\Filterable\Exceptions\MissingBuilderException + */ + private function initQueryBuilderInstance(?Builder $builder = null) + { + if ($builder) { + return $builder; + } + + if (isset($this->builder)) { + return $this->builder; + } + + if ($this->model instanceof Model) { + return $this->model->query(); + } + + if (is_a($this->model, Model::class, true)) { + return $this->model::query(); + } + + throw new MissingBuilderException(); + } + + /** + * Set model. + * + * @param \Illuminate\Database\Eloquent\Model|string $model + * + * @return static + */ + public function setModel(Model|string $model): static + { + $this->model = $model; + + return $this; + } + + /** + * Get model. + * + * @return Model|string + */ + public function getModel() + { + return $this->model; + } + + /** + * Get model instance object. + * + * @return Model|object|null + */ + public function getModelInstance() + { + if ($this->model instanceof Model) { + return $this->model; + } + + if (is_a($this->model, Model::class, true)) { + return new $this->model(); + } + + return null; + } + + /** + * Create new Filterable instance. + * + * @param \Illuminate\Http\Request|null $request + * + * @return static + */ + public static function create(?Request $request = null): static + { + return new static($request ?? App::make(Request::class)); + } + + /** + * Apply a callback conditionally and return a new modified instance. + * + * @param bool $condition + * @param callable(static): void $callback + * + * @return static + * + * @link https://kettasoft.github.io/filterable/features/conditional-logic + */ + public function when(bool $condition, callable $callback) + { + if ($condition) { + call_user_func($callback, $this); + } + + return $this; + } + + /** + * Inverse of `when` method. + * + * @param bool $condition + * @param callable(static): void $callback + * + * @return static + * + * @link https://kettasoft.github.io/filterable/features/conditional-logic + */ + public function unless(bool $condition, callable $callback) + { + return $this->when(!$condition, $callback); + } + + /** + * Allow the query to pass through a custom pipeline of pipes (callables). + * + * @param array $pipes + * + * @return static + * + * @link https://kettasoft.github.io/filterable/features/through + */ + public function through(array $pipes): static + { + foreach ($pipes as $pipe) { + if (!is_callable($pipe)) { + throw new \InvalidArgumentException('All pipes passed to `through` must be callable.'); + } + + $pipe($this->builder, $this); + } + + return $this; + } + + /** + * Override the default engine for this filterable instance. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Engine|string $engine + * + * @return Filterable + */ + public function useEngine(Engine|string $engine): static + { + $this->engine = EngineManager::generate($engine, $this); + + return $this; + } + + /** + * Get current engine. + * + * @return Engine + */ + public function getEngine(): Engine + { + return $this->engine; + } + + /** + * Get the current request instance. + * + * @return Request + */ + public function getRequest(): Request + { + return $this->request; + } + + /** + * Get sanitizer instance. + * + * @return Sanitizer + */ + public function getSanitizerInstance(): Sanitizer + { + return $this->sanitizer; + } + + /** + * Set manual data injection. + * + * @param array $data + * @param bool $override + * + * @return static + */ + public function setData(array $data, bool $override = true): static + { + $this->data = $override ? $data : array_merge($this->data, $data); + + return $this; + } + + /** + * Create new Filterable instance with custom Request. + * + * @param \Illuminate\Http\Request $request + * + * @return static + */ + public static function withRequest(Request $request): static + { + return static::create($request); + } + + /** + * Set a new sanitizers classes. + * + * @param array $sanitizers + * + * @return Filterable + */ + public function setSanitizers(array $sanitizers, bool $override = true): static + { + $this->sanitizers = $override ? $sanitizers : array_merge($this->sanitizers, $sanitizers); + $this->sanitizer->setSanitizers($this->sanitizers); + + return $this; + } + + /** + * Disable running sanitizers on the filters. + * + * @return static + */ + public function withoutSanitizers(): static + { + $this->sanitizers = []; + $this->sanitizer->setSanitizers([]); + + return $this; + } + + /** + * Parse incomming data from request. + * + * @return void + */ + private function parseIncomingRequestData() + { + $this->data = [...$this->request->all(), ...$this->request->json()->all()]; + } + + /** + * Get current data. + * + * @return array + */ + public function getData(): mixed + { + return $this->filterKey === null ? $this->data : $this->data[$this->filterKey] ?? $this->data; + } + + /** + * Fetch all relevant filters from the filter API class. + * + * @return array + */ + public function getFilterAttributes(): array + { + return property_exists($this, 'filters') + && is_array($this->filters) ? $this->filters : []; + } + + /** + * Resolve default engine to Filterable instance. + * + * @return void + */ + private function resolveEngine() + { + $this->useEngine((new HeaderDrivenEngineSelector($this->request))->resolve()); + } + + /** + * Set request source. + * + * @param string $source + * + * @throws \Kettasoft\Filterable\Exceptions\RequestSourceIsNotSupportedException + * + * @return static + */ + public function setSource(string $source) + { + if (!in_array($source, ['query', 'input', 'json'])) { + throw new RequestSourceIsNotSupportedException($source); + } + + $this->requestSource = $source; + + return $this; + } + + /** + * Ignore empty or null values. + * + * @return Filterable + */ + public function ignoreEmptyValues(): static + { + $this->ignoreEmptyValues = true; + + return $this; + } + + /** + * Check if current filterable class has ignored empty values. + * + * @return bool + */ + public function hasIgnoredEmptyValues(): bool + { + return $this->ignoreEmptyValues === true; + } + + /** + * Enable Header-driven mode per request. + * + * @param mixed $config + * + * @return Filterable + */ + public function withHeaderDrivenMode($config = []): static + { + return $this->useEngine((new HeaderDrivenEngineSelector($this->request, array_merge( + config('filterable.header_driven_mode', []), + ['enabled' => true], + $config + )))->resolve()); + } + + /** + * Get allowed fields to apply filtering. + * + * @return array + */ + public function getAllowedFields(): array + { + return $this->allowedFields; + } + + /** + * List of supported SQL operators you want to allow when parsing the expressions. + * + * @return array + */ + public function getAllowedOperators(): array + { + return $this->allowedOperators; + } + + /** + * Set allowed operators and override global operators. + * + * @param array $operators + * + * @return static + */ + public function allowedOperators(array $operators): static + { + $this->allowedOperators = $operators; + + return $this; + } + + /** + * Define allowed fields to filtering. + * + * @param array $fields + * + * @return Filterable + */ + public function setAllowedFields(array $fields, bool $override = false): static + { + $this->allowedFields = $override ? $fields : array_merge($this->allowedFields, $fields); + $this->resources->fields->fill($this->allowedFields); + + return $this; + } + + /** + * Enable strict mode in this instance. + * + * @return Filterable + */ + public function strict(): static + { + $this->strict = true; + + return $this; + } + + /** + * Enable strict mode in this instance. + * + * @return Filterable + */ + public function permissive(): static + { + $this->strict = false; + + return $this; + } + + /** + * Check if filter has strict mode. + * + * @return mixed + */ + public function isStrict() + { + if (is_bool($this->strict)) { + return $this->strict; + } + + return null; + } + + /** + * Get columns wrapper. + * + * @return array + */ + public function getFieldsMap(): array + { + return $this->fieldsMap; + } + + /** + * Set fields wrapper. + * + * @param array $fields + * @param bool $override + * + * @return static + */ + public function setFieldsMap($fields, bool $override = true): static + { + $this->fieldsMap = $override ? $fields : array_merge($this->fieldsMap, $fields); + + return $this; + } + + /** + * Get registered filter builder. + * + * @return Builder + */ + public function getBuilder(): Builder + { + return $this->builder; + } + + /** + * Set a new builder. + * + * @param Builder $builder + * + * @return static + */ + public function setBuilder(Builder $builder): static + { + $this->builder = $builder; + + return $this; + } + + /** + * Auto-detect filterable fields from model fillable attributes. + * + * @param bool $override To override current fields + * + * @return static + */ + public function autoSetAllowedFieldsFromModel(bool $override = false): static + { + $fillable = $this->builder->getModel()->getFillable(); + $this->allowedFields = $override ? $fillable : array_merge($this->allowedFields, $fillable); + + return $this; + } + + /** + * Get the SQL representation of the filtered query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder|null $builder + * @param mixed $withBindings + * + * @return string + */ + public function toSql(?Builder $builder = null, $withBindings = false): string + { + $builder = $this->apply($builder ?? $this->builder); + + return $withBindings ? $builder->toRawSql() : $builder->toSql(); + } + + /** + * Retrieve an input item from the request. + * + * @param string $key + * + * @return mixed + */ + public function get(string $key) + { + if (!in_array($source = $this->requestSource ?? config('filterable.request_source', 'query'), ['query', 'input', 'json'])) { + throw new RequestSourceIsNotSupportedException($source); + } + + return $this->request->{$source}($key); + } + + /** + * Get exception handler instance. + * + * @return ExceptionHandlerInterface + */ + public function getExceptionHandler(): ExceptionHandlerInterface + { + $config = config('filterable.exceptions'); + $engineOverrides = config("filterable.engines.{$this->engine->getEngineName()}.exceptions", []); + + $merged = array_merge($config, $engineOverrides); + + return app($merged['handler']); + } + + /** + * Dynamically retrieve attributes from the request. + * + * @param mixed $property + * + * @return mixed + */ + public function __get($property): mixed + { + if (property_exists($this, $property)) { + return $this->{$property}; + } + + return $this->get($property); + } + + /** + * Handle dynamic method calls to the builder. + * + * @param string $method + * @param array $parameters + * + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->handleFluentReturn($method, $parameters); + } } diff --git a/src/Foundation/Bags/Bag.php b/src/Foundation/Bags/Bag.php index 8056bef..c1f5cea 100644 --- a/src/Foundation/Bags/Bag.php +++ b/src/Foundation/Bags/Bag.php @@ -8,189 +8,215 @@ use IteratorAggregate; /** - * Class Bag - * + * Class Bag. + * * Abstract base class for all Bags (FieldBag, RelationBag, etc). * Providers shared array-like behaviors and utility methods. - * + * * @template TKey of array-key * @template TValue */ abstract class Bag implements Countable, Arrayable, ArrayAccess, IteratorAggregate { - /** - * Internal storage of the bag items. - * @var array - */ - protected array $items = []; - - /** - * Internal aliases for the items. - * @var array - */ - protected array $aliases = []; - - /** - * Create a new Bag instance. - * @param array $items - */ - public function __construct(array $items = []) - { - $this->items = $items; - } - - /** - * Get all items as array - * @return array - */ - public function all(): array - { - return $this->items; - } - - /** - * Determine if the given key exists. - * @param TKey $key - * @return bool - */ - public function has($key): bool - { - return array_key_exists($key, $this->items) || in_array($key, $this->items); - } - - /** - * Get the item by key. - * - * @param TKey $key - * @param mixed|null $default - * @return TValue|null - */ - public function get($key, $default = null) - { - return $this->items[$key] ?? $default; - } - - /** - * Set an item by key. - * @param TKey $key - * @param TValue $value - * @return void - */ - public function set($key, $value = null) - { - if (count(func_get_args()) === 1) { - dd($key); - $this->items[] = $key; - } else { - $this->items[$key] = $value; - } - } - - /** - * Fill the items - * @param array $items - * @return void - */ - public function fill(array $items) - { - $this->items = $items; - } - - /** - * Merge the items. - * @param array $items - * @return void - */ - public function merge(array $items) - { - $this->items = array_merge($this->items, $items); - } - - /** - * Remove an item by key - * @param TKey $key - * @return void - */ - public function forget($key) - { - unset($this->items[$key]); - } - - public function aliases(array $aliases = []) - { - $this->aliases = $aliases; - } - - /** - * Return number of items. - * @return int - */ - public function count(): int - { - return count($this->items); - } - - /** - * Convert to array. - * @return array - */ - public function toArray() - { - return $this->items; - } - - public function getIterator(): \Traversable - { - return new \ArrayIterator($this->items); - } - - /** - * Determine if the given offset exists. - * @param mixed $offset - * @return bool - */ - public function offsetExists(mixed $offset): bool - { - return isset($this->items[$offset]); - } - - /** - * Get item at offset. - * @param TKey $offset - * @return TValue|null - */ - public function offsetGet(mixed $offset): mixed - { - return $this->items[$offset] ?? null; - } - - /** - * Set item at offset - * @param TKey $offset - * @param TValue $value - * @return void - */ - public function offsetSet(mixed $offset, mixed $value): void - { - $this->items[$offset] = $value; - } - - /** - * Unset item at offset. - * @param TKey $offset - * @return void - */ - public function offsetUnset(mixed $offset): void - { - unset($this->items[$offset]); - } - - /** - * Reset the Bag. - * @return void - */ - public function clear() - { - $this->items = []; - } + /** + * Internal storage of the bag items. + * + * @var array + */ + protected array $items = []; + + /** + * Internal aliases for the items. + * + * @var array + */ + protected array $aliases = []; + + /** + * Create a new Bag instance. + * + * @param array $items + */ + public function __construct(array $items = []) + { + $this->items = $items; + } + + /** + * Get all items as array. + * + * @return array + */ + public function all(): array + { + return $this->items; + } + + /** + * Determine if the given key exists. + * + * @param TKey $key + * + * @return bool + */ + public function has($key): bool + { + return array_key_exists($key, $this->items) || in_array($key, $this->items); + } + + /** + * Get the item by key. + * + * @param TKey $key + * @param mixed|null $default + * + * @return TValue|null + */ + public function get($key, $default = null) + { + return $this->items[$key] ?? $default; + } + + /** + * Set an item by key. + * + * @param TKey $key + * @param TValue $value + * + * @return void + */ + public function set($key, $value = null) + { + if (count(func_get_args()) === 1) { + dd($key); + $this->items[] = $key; + } else { + $this->items[$key] = $value; + } + } + + /** + * Fill the items. + * + * @param array $items + * + * @return void + */ + public function fill(array $items) + { + $this->items = $items; + } + + /** + * Merge the items. + * + * @param array $items + * + * @return void + */ + public function merge(array $items) + { + $this->items = array_merge($this->items, $items); + } + + /** + * Remove an item by key. + * + * @param TKey $key + * + * @return void + */ + public function forget($key) + { + unset($this->items[$key]); + } + + public function aliases(array $aliases = []) + { + $this->aliases = $aliases; + } + + /** + * Return number of items. + * + * @return int + */ + public function count(): int + { + return count($this->items); + } + + /** + * Convert to array. + * + * @return array + */ + public function toArray() + { + return $this->items; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + /** + * Determine if the given offset exists. + * + * @param mixed $offset + * + * @return bool + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->items[$offset]); + } + + /** + * Get item at offset. + * + * @param TKey $offset + * + * @return TValue|null + */ + public function offsetGet(mixed $offset): mixed + { + return $this->items[$offset] ?? null; + } + + /** + * Set item at offset. + * + * @param TKey $offset + * @param TValue $value + * + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->items[$offset] = $value; + } + + /** + * Unset item at offset. + * + * @param TKey $offset + * + * @return void + */ + public function offsetUnset(mixed $offset): void + { + unset($this->items[$offset]); + } + + /** + * Reset the Bag. + * + * @return void + */ + public function clear() + { + $this->items = []; + } } diff --git a/src/Foundation/Bags/FieldBag.php b/src/Foundation/Bags/FieldBag.php index 6bb638f..509f04d 100644 --- a/src/Foundation/Bags/FieldBag.php +++ b/src/Foundation/Bags/FieldBag.php @@ -2,12 +2,12 @@ namespace Kettasoft\Filterable\Foundation\Bags; -use Kettasoft\Filterable\Foundation\Bags\Bag; - /** - * Class FieldBag + * Class FieldBag. * * This class extends the Bag class to specifically handle field-related data. * It is used to manage and access filterable fields in a structured way. */ -class FieldBag extends Bag {} +class FieldBag extends Bag +{ +} diff --git a/src/Foundation/Bags/FieldMapBag.php b/src/Foundation/Bags/FieldMapBag.php index f7b8497..6b56bde 100644 --- a/src/Foundation/Bags/FieldMapBag.php +++ b/src/Foundation/Bags/FieldMapBag.php @@ -3,9 +3,11 @@ namespace Kettasoft\Filterable\Foundation\Bags; /** - * Class FieldMapBag + * Class FieldMapBag. * * This class extends the Bag class to specifically handle field map-related data. * It is used to manage and access filterable field maps in a structured way. */ -class FieldMapBag extends Bag {} +class FieldMapBag extends Bag +{ +} diff --git a/src/Foundation/Bags/OperatorBag.php b/src/Foundation/Bags/OperatorBag.php index 7189b6d..53d1dee 100644 --- a/src/Foundation/Bags/OperatorBag.php +++ b/src/Foundation/Bags/OperatorBag.php @@ -3,30 +3,30 @@ namespace Kettasoft\Filterable\Foundation\Bags; /** - * Class OperatorBag + * Class OperatorBag. * * This class extends the Bag class to specifically handle operator-related data. * It is used to manage and access filterable operators in a structured way. */ class OperatorBag extends Bag { - /** - * Default operator for the bag. - * - * @var string - */ - public string $default; + /** + * Default operator for the bag. + * + * @var string + */ + public string $default; - /** - * OperatorBag constructor. - * - * Initializes the bag with the provided operators and sets a default operator. - * - * @param array $operators - * @param string $default - */ - public function setDefault(string $operator) - { - $this->default = $operator; - } + /** + * OperatorBag constructor. + * + * Initializes the bag with the provided operators and sets a default operator. + * + * @param array $operators + * @param string $default + */ + public function setDefault(string $operator) + { + $this->default = $operator; + } } diff --git a/src/Foundation/Bags/RelationBag.php b/src/Foundation/Bags/RelationBag.php index 06969c7..0080390 100644 --- a/src/Foundation/Bags/RelationBag.php +++ b/src/Foundation/Bags/RelationBag.php @@ -3,9 +3,11 @@ namespace Kettasoft\Filterable\Foundation\Bags; /** - * Class RelationBag + * Class RelationBag. * * This class extends the Bag class to specifically handle relation-related data. * It is used to manage and access filterable relations in a structured way. */ -class RelationBag extends Bag {} +class RelationBag extends Bag +{ +} diff --git a/src/Foundation/Bags/SanitizerBag.php b/src/Foundation/Bags/SanitizerBag.php index 381c990..e5432e2 100644 --- a/src/Foundation/Bags/SanitizerBag.php +++ b/src/Foundation/Bags/SanitizerBag.php @@ -3,9 +3,11 @@ namespace Kettasoft\Filterable\Foundation\Bags; /** - * Class SanitizerBag + * Class SanitizerBag. * * This class extends the Bag class to specifically handle sanitizer-related data. * It is used to manage and access filterable sanitizers in a structured way. */ -class SanitizerBag extends Bag {} +class SanitizerBag extends Bag +{ +} diff --git a/src/Foundation/Caching/CacheInvalidationObserver.php b/src/Foundation/Caching/CacheInvalidationObserver.php index 570cf92..ecbd7c1 100644 --- a/src/Foundation/Caching/CacheInvalidationObserver.php +++ b/src/Foundation/Caching/CacheInvalidationObserver.php @@ -3,34 +3,31 @@ namespace Kettasoft\Filterable\Foundation\Caching; use Illuminate\Database\Eloquent\Model; -use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; /** - * CacheInvalidationObserver - Automatically invalidate caches when models change + * CacheInvalidationObserver - Automatically invalidate caches when models change. * * Observes model events (created, updated, deleted) and flushes * associated cache tags to ensure data consistency. - * - * @package Kettasoft\Filterable\Foundation\Caching */ class CacheInvalidationObserver { /** - * Cache manager instance + * Cache manager instance. * * @var FilterableCacheManager */ protected FilterableCacheManager $cacheManager; /** - * Model-to-tags mapping from configuration + * Model-to-tags mapping from configuration. * * @var array */ protected array $modelTagsMap; /** - * Constructor + * Constructor. */ public function __construct() { @@ -39,9 +36,10 @@ public function __construct() } /** - * Handle the Model "created" event + * Handle the Model "created" event. * * @param Model $model + * * @return void */ public function created(Model $model): void @@ -50,9 +48,10 @@ public function created(Model $model): void } /** - * Handle the Model "updated" event + * Handle the Model "updated" event. * * @param Model $model + * * @return void */ public function updated(Model $model): void @@ -61,9 +60,10 @@ public function updated(Model $model): void } /** - * Handle the Model "deleted" event + * Handle the Model "deleted" event. * * @param Model $model + * * @return void */ public function deleted(Model $model): void @@ -72,9 +72,10 @@ public function deleted(Model $model): void } /** - * Handle the Model "force deleted" event + * Handle the Model "force deleted" event. * * @param Model $model + * * @return void */ public function forceDeleted(Model $model): void @@ -83,9 +84,10 @@ public function forceDeleted(Model $model): void } /** - * Handle the Model "restored" event + * Handle the Model "restored" event. * * @param Model $model + * * @return void */ public function restored(Model $model): void @@ -94,9 +96,10 @@ public function restored(Model $model): void } /** - * Invalidate cache for a model + * Invalidate cache for a model. * * @param Model $model + * * @return void */ protected function invalidateCacheForModel(Model $model): void @@ -117,9 +120,10 @@ protected function invalidateCacheForModel(Model $model): void } /** - * Get cache tags for a model class + * Get cache tags for a model class. * * @param string $modelClass + * * @return array */ protected function getTagsForModel(string $modelClass): array @@ -128,10 +132,11 @@ protected function getTagsForModel(string $modelClass): array } /** - * Log cache invalidation + * Log cache invalidation. * * @param string $modelClass - * @param array $tags + * @param array $tags + * * @return void */ protected function logInvalidation(string $modelClass, array $tags): void @@ -141,15 +146,15 @@ protected function logInvalidation(string $modelClass, array $tags): void \Illuminate\Support\Facades\Log::channel($channel)->info( 'Filterable cache invalidated', [ - 'model' => $modelClass, - 'tags' => $tags, + 'model' => $modelClass, + 'tags' => $tags, 'timestamp' => now()->toIso8601String(), ] ); } /** - * Register the observer for auto-invalidation + * Register the observer for auto-invalidation. * * @return void */ diff --git a/src/Foundation/Caching/CacheKeyGenerator.php b/src/Foundation/Caching/CacheKeyGenerator.php index 55fda07..7c90a3a 100644 --- a/src/Foundation/Caching/CacheKeyGenerator.php +++ b/src/Foundation/Caching/CacheKeyGenerator.php @@ -6,31 +6,29 @@ use Illuminate\Support\Str; /** - * CacheKeyGenerator - Generates deterministic cache keys + * CacheKeyGenerator - Generates deterministic cache keys. * * Creates unique, deterministic cache keys for filterable operations * based on filter class, query parameters, data provisioning, and scopes. - * - * @package Kettasoft\Filterable\Foundation\Caching */ class CacheKeyGenerator { /** - * Cache key prefix + * Cache key prefix. * * @var string */ protected string $prefix; /** - * Cache key version for invalidation + * Cache key version for invalidation. * * @var string */ protected string $version; /** - * Constructor + * Constructor. * * @param string|null $prefix * @param string|null $version @@ -42,13 +40,14 @@ public function __construct(?string $prefix = null, ?string $version = null) } /** - * Generate a cache key for a filterable operation + * Generate a cache key for a filterable operation. * - * @param string $filterClass - * @param array $filters - * @param array $providedData - * @param array $scopes + * @param string $filterClass + * @param array $filters + * @param array $providedData + * @param array $scopes * @param Builder|null $query + * * @return string */ public function generate( @@ -76,9 +75,10 @@ public function generate( } /** - * Generate a simple cache key from components + * Generate a simple cache key from components. * * @param string ...$components + * * @return string */ public function simple(string ...$components): string @@ -87,9 +87,10 @@ public function simple(string ...$components): string } /** - * Normalize class name for cache key + * Normalize class name for cache key. * * @param string $className + * * @return string */ protected function normalizeClassName(string $className): string @@ -99,9 +100,10 @@ protected function normalizeClassName(string $className): string } /** - * Hash filters array into deterministic string + * Hash filters array into deterministic string. * * @param array $filters + * * @return string */ protected function hashFilters(array $filters): string @@ -124,9 +126,10 @@ protected function hashFilters(array $filters): string } /** - * Hash provided data into deterministic string + * Hash provided data into deterministic string. * * @param array $providedData + * * @return string */ protected function hashProvidedData(array $providedData): string @@ -137,13 +140,14 @@ protected function hashProvidedData(array $providedData): string ksort($providedData); - return 'data_' . md5(json_encode($providedData)); + return 'data_'.md5(json_encode($providedData)); } /** - * Hash scopes into deterministic string + * Hash scopes into deterministic string. * * @param array $scopes + * * @return string */ protected function hashScopes(array $scopes): string @@ -154,13 +158,14 @@ protected function hashScopes(array $scopes): string ksort($scopes); - return 'scope_' . md5(json_encode($scopes)); + return 'scope_'.md5(json_encode($scopes)); } /** - * Generate a fingerprint for the query builder + * Generate a fingerprint for the query builder. * * @param Builder $query + * * @return string */ protected function generateQueryFingerprint(Builder $query): string @@ -170,16 +175,17 @@ protected function generateQueryFingerprint(Builder $query): string $bindings = $query->getBindings(); // Create fingerprint - return 'query_' . md5($sql . json_encode($bindings)); + return 'query_'.md5($sql.json_encode($bindings)); } /** - * Generate a cache key with user scope + * Generate a cache key with user scope. * - * @param string $filterClass + * @param string $filterClass * @param int|string $userId - * @param array $filters - * @param array $providedData + * @param array $filters + * @param array $providedData + * * @return string */ public function forUser( @@ -197,12 +203,13 @@ public function forUser( } /** - * Generate a cache key with tenant scope + * Generate a cache key with tenant scope. * - * @param string $filterClass + * @param string $filterClass * @param int|string $tenantId - * @param array $filters - * @param array $providedData + * @param array $filters + * @param array $providedData + * * @return string */ public function forTenant( @@ -220,12 +227,13 @@ public function forTenant( } /** - * Generate a cache key with custom scopes + * Generate a cache key with custom scopes. * * @param string $filterClass - * @param array $scopes - * @param array $filters - * @param array $providedData + * @param array $scopes + * @param array $filters + * @param array $providedData + * * @return string */ public function withScopes( @@ -243,7 +251,7 @@ public function withScopes( } /** - * Get the cache key prefix + * Get the cache key prefix. * * @return string */ @@ -253,19 +261,21 @@ public function getPrefix(): string } /** - * Set the cache key prefix + * Set the cache key prefix. * * @param string $prefix + * * @return self */ public function setPrefix(string $prefix): self { $this->prefix = $prefix; + return $this; } /** - * Get the cache key version + * Get the cache key version. * * @return string */ @@ -275,14 +285,16 @@ public function getVersion(): string } /** - * Set the cache key version + * Set the cache key version. * * @param string $version + * * @return self */ public function setVersion(string $version): self { $this->version = $version; + return $this; } } diff --git a/src/Foundation/Caching/FilterableCacheManager.php b/src/Foundation/Caching/FilterableCacheManager.php index b227c41..5f9d899 100644 --- a/src/Foundation/Caching/FilterableCacheManager.php +++ b/src/Foundation/Caching/FilterableCacheManager.php @@ -2,76 +2,74 @@ namespace Kettasoft\Filterable\Foundation\Caching; -use Illuminate\Contracts\Cache\Repository as CacheRepository; -use Illuminate\Support\Facades\Cache; +use DateTimeInterface; use Illuminate\Cache\TaggableStore; use Illuminate\Cache\TaggedCache; -use DateTimeInterface; +use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Support\Facades\Cache; /** - * FilterableCacheManager - Singleton cache management for Filterable + * FilterableCacheManager - Singleton cache management for Filterable. * * Provides centralized caching operations for all filterable instances with * support for TTL, tags, scopes, profiles, and auto-invalidation. - * - * @package Kettasoft\Filterable\Foundation\Caching */ class FilterableCacheManager { /** - * Singleton instance + * Singleton instance. * * @var FilterableCacheManager|null */ private static ?FilterableCacheManager $instance = null; /** - * Cache repository instance + * Cache repository instance. * * @var CacheRepository */ protected CacheRepository $cache; /** - * Cache configuration + * Cache configuration. * * @var array */ protected array $config; /** - * Active cache tags + * Active cache tags. * * @var array */ protected array $tags = []; /** - * Active cache scopes + * Active cache scopes. * * @var array */ protected array $scopes = []; /** - * Current cache profile + * Current cache profile. * * @var string|null */ protected ?string $profile = null; /** - * Whether caching is globally enabled + * Whether caching is globally enabled. * * @var bool */ protected bool $enabled = true; /** - * Private constructor for singleton + * Private constructor for singleton. * * @param CacheRepository $cache - * @param array $config + * @param array $config */ private function __construct(CacheRepository $cache, array $config = []) { @@ -81,7 +79,7 @@ private function __construct(CacheRepository $cache, array $config = []) } /** - * Get singleton instance + * Get singleton instance. * * @return FilterableCacheManager */ @@ -101,7 +99,7 @@ public static function getInstance(): FilterableCacheManager } /** - * Reset singleton instance (for testing) + * Reset singleton instance (for testing). * * @return void */ @@ -111,11 +109,12 @@ public static function resetInstance(): void } /** - * Cache a value with the given key + * Cache a value with the given key. * - * @param string $key - * @param mixed $value + * @param string $key + * @param mixed $value * @param DateTimeInterface|int|null $ttl + * * @return bool */ public function put(string $key, mixed $value, DateTimeInterface|int|null $ttl = null): bool @@ -131,11 +130,12 @@ public function put(string $key, mixed $value, DateTimeInterface|int|null $ttl = } /** - * Get a value from cache or execute callback and cache result + * Get a value from cache or execute callback and cache result. * - * @param string $key + * @param string $key * @param DateTimeInterface|int|null $ttl - * @param callable $callback + * @param callable $callback + * * @return mixed */ public function remember(string $key, DateTimeInterface|int|null $ttl, callable $callback): mixed @@ -151,10 +151,11 @@ public function remember(string $key, DateTimeInterface|int|null $ttl, callable } /** - * Cache a value forever + * Cache a value forever. * * @param string $key - * @param mixed $value + * @param mixed $value + * * @return bool */ public function forever(string $key, mixed $value): bool @@ -169,10 +170,11 @@ public function forever(string $key, mixed $value): bool } /** - * Get a value from cache or execute callback and cache forever + * Get a value from cache or execute callback and cache forever. * - * @param string $key + * @param string $key * @param callable $callback + * * @return mixed */ public function rememberForever(string $key, callable $callback): mixed @@ -187,10 +189,11 @@ public function rememberForever(string $key, callable $callback): mixed } /** - * Retrieve a value from cache + * Retrieve a value from cache. * - * @param string $key + * @param string $key * @param mixed|null $default + * * @return mixed */ public function get(string $key, $default = null): mixed @@ -205,9 +208,10 @@ public function get(string $key, $default = null): mixed } /** - * Check if a key exists in cache + * Check if a key exists in cache. * * @param string $key + * * @return bool */ public function has(string $key): bool @@ -222,9 +226,10 @@ public function has(string $key): bool } /** - * Remove a value from cache + * Remove a value from cache. * * @param string $key + * * @return bool */ public function forget(string $key): bool @@ -235,9 +240,10 @@ public function forget(string $key): bool } /** - * Flush all cache entries with the given tags + * Flush all cache entries with the given tags. * * @param array $tags + * * @return bool */ public function flushByTags(array $tags): bool @@ -249,6 +255,7 @@ public function flushByTags(array $tags): bool if ($this->cache->getStore() instanceof TaggableStore) { /** @var \Illuminate\Cache\CacheManager $cacheManager */ $cacheManager = app('cache'); + return $cacheManager->tags($tags)->flush(); } @@ -257,58 +264,67 @@ public function flushByTags(array $tags): bool } /** - * Set cache tags for the next operation + * Set cache tags for the next operation. * * @param array $tags + * * @return self */ public function withTags(array $tags): self { $this->tags = $tags; + return $this; } /** - * Set cache scopes for the next operation + * Set cache scopes for the next operation. * * @param array $scopes + * * @return self */ public function withScopes(array $scopes): self { $this->scopes = $scopes; + return $this; } /** - * Add a single scope + * Add a single scope. * * @param string $key - * @param mixed $value + * @param mixed $value + * * @return self */ public function addScope(string $key, mixed $value): self { $this->scopes[$key] = $value; + return $this; } /** - * Set cache profile for the next operation + * Set cache profile for the next operation. * * @param string $profile + * * @return self */ public function withProfile(string $profile): self { $this->profile = $profile; + return $this; } /** - * Generate a cache key with scopes + * Generate a cache key with scopes. * * @param string $baseKey + * * @return string */ public function generateKey(string $baseKey): string @@ -319,14 +335,14 @@ public function generateKey(string $baseKey): string $scopeString = collect($this->scopes) ->sortKeys() - ->map(fn($value, $key) => "{$key}:{$value}") + ->map(fn ($value, $key) => "{$key}:{$value}") ->implode(':'); return "{$baseKey}:{$scopeString}"; } /** - * Get cache instance with tags if applicable + * Get cache instance with tags if applicable. * * @return CacheRepository|TaggedCache */ @@ -335,6 +351,7 @@ protected function getCacheInstance(): CacheRepository|TaggedCache if (!empty($this->tags) && $this->cache->getStore() instanceof TaggableStore) { /** @var \Illuminate\Cache\CacheManager $cacheManager */ $cacheManager = app('cache'); + return $cacheManager->tags($this->tags); } @@ -342,7 +359,7 @@ protected function getCacheInstance(): CacheRepository|TaggedCache } /** - * Get default TTL from config + * Get default TTL from config. * * @return int */ @@ -356,9 +373,10 @@ protected function getDefaultTtl(): int } /** - * Get profile configuration + * Get profile configuration. * * @param string $profile + * * @return array */ public function getProfileConfig(string $profile): array @@ -367,9 +385,10 @@ public function getProfileConfig(string $profile): array } /** - * Check if a profile exists + * Check if a profile exists. * * @param string $profile + * * @return bool */ public function hasProfile(string $profile): bool @@ -378,29 +397,31 @@ public function hasProfile(string $profile): bool } /** - * Enable caching globally + * Enable caching globally. * * @return self */ public function enable(): self { $this->enabled = true; + return $this; } /** - * Disable caching globally + * Disable caching globally. * * @return self */ public function disable(): self { $this->enabled = false; + return $this; } /** - * Check if caching is enabled + * Check if caching is enabled. * * @return bool */ @@ -410,7 +431,7 @@ public function isEnabled(): bool } /** - * Reset tags, scopes, and profile for next operation + * Reset tags, scopes, and profile for next operation. * * @return self */ @@ -419,11 +440,12 @@ public function reset(): self $this->tags = []; $this->scopes = []; $this->profile = null; + return $this; } /** - * Get current tags + * Get current tags. * * @return array */ @@ -433,7 +455,7 @@ public function getTags(): array } /** - * Get current scopes + * Get current scopes. * * @return array */ @@ -443,7 +465,7 @@ public function getScopes(): array } /** - * Get current profile + * Get current profile. * * @return string|null */ @@ -453,7 +475,7 @@ public function getProfile(): ?string } /** - * Prevent cloning + * Prevent cloning. */ private function __clone() { @@ -461,10 +483,10 @@ private function __clone() } /** - * Prevent unserialization + * Prevent unserialization. */ public function __wakeup() { - throw new \Exception("Cannot unserialize singleton"); + throw new \Exception('Cannot unserialize singleton'); } } diff --git a/src/Foundation/Contracts/FilterableProfile.php b/src/Foundation/Contracts/FilterableProfile.php index fd969c0..f539da4 100644 --- a/src/Foundation/Contracts/FilterableProfile.php +++ b/src/Foundation/Contracts/FilterableProfile.php @@ -8,7 +8,9 @@ interface FilterableProfile { /** * Handle the given filterable context. + * * @param Filterable $context + * * @return Filterable */ public function __invoke(Filterable $context): Filterable; diff --git a/src/Foundation/Contracts/HasDynamicCalls.php b/src/Foundation/Contracts/HasDynamicCalls.php index 88e61ab..9e49833 100644 --- a/src/Foundation/Contracts/HasDynamicCalls.php +++ b/src/Foundation/Contracts/HasDynamicCalls.php @@ -4,17 +4,16 @@ /** * Interface for classes that support dynamic method calls. - * - * @package Kettasoft\Filterable\Foundation\Contracts */ interface HasDynamicCalls { - /** - * Handle dynamic method calls. - * - * @param string $method - * @param array $args - * @return mixed - */ - public function __call($method, $args); + /** + * Handle dynamic method calls. + * + * @param string $method + * @param array $args + * + * @return mixed + */ + public function __call($method, $args); } diff --git a/src/Foundation/Contracts/QueryBuilderInterface.php b/src/Foundation/Contracts/QueryBuilderInterface.php index bdcb2c1..ea47ad1 100644 --- a/src/Foundation/Contracts/QueryBuilderInterface.php +++ b/src/Foundation/Contracts/QueryBuilderInterface.php @@ -8,4 +8,6 @@ /** * @mixin \Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Eloquent\Builder */ -interface QueryBuilderInterface extends EloquentBuilder, QueryBuilder {} +interface QueryBuilderInterface extends EloquentBuilder, QueryBuilder +{ +} diff --git a/src/Foundation/Contracts/ShouldReturnQueryBuilder.php b/src/Foundation/Contracts/ShouldReturnQueryBuilder.php index fac256f..18e164e 100644 --- a/src/Foundation/Contracts/ShouldReturnQueryBuilder.php +++ b/src/Foundation/Contracts/ShouldReturnQueryBuilder.php @@ -2,4 +2,6 @@ namespace Kettasoft\Filterable\Foundation\Contracts; -interface ShouldReturnQueryBuilder {} +interface ShouldReturnQueryBuilder +{ +} diff --git a/src/Foundation/Contracts/Sortable.php b/src/Foundation/Contracts/Sortable.php index 1912871..d858e60 100644 --- a/src/Foundation/Contracts/Sortable.php +++ b/src/Foundation/Contracts/Sortable.php @@ -6,117 +6,128 @@ interface Sortable { - /** - * Apply sorting to the query. - * - * @param \Illuminate\Contracts\Database\Eloquent\Builder $query - * @return Builder - */ - public function apply(Builder $query): Builder; - - /** - * Define which fields are allowed for sorting. - * - * @param array $fields - * @return $this - */ - public function allow(array $fields): self; - - /** - * Map input fields to database columns. - * - * @param array $fields - * @return $this - */ - public function map(array $fields): self; - - /** - * Define a default sorting field and direction. - * - * @param string $field - * @param string $direction 'asc' or 'desc' - * @return $this - */ - public function default(string $field, string $direction = 'asc'): self; - - /** - * Add an alias (preset) for sorting. - * - * Example: - * $sortable->alias("popular", [['views', 'desc'], ['likes', 'desc']]); - * - * @param string $name - * @param array $sorting - * @return $this - */ - public function alias(string $name, array $sorting): self; - - /** - * Get the sorting configuration. - * - * @return array - */ - public function getConfig(): array; - - /** - * Get the allowed sorting fields. - * - * @return array - */ - public function getAllowed(): array; - - /** - * Get the default sorting field and direction. - * - * @return array|null - */ - public function getDefault(): ?array; - - /** - * Get the sorting aliases. - * - * @return array - */ - public function getAliases(): array; - - /** - * Set the request key to look for sorting parameters. - * - * @param string $key - * @return $this - * @throws \InvalidArgumentException - */ - public function setSortKey(string $key): self; - - /** - * Get the sort key used in the request. - * - * @return string - */ - public function getSortKey(): string; - - /** - * Set the delimiter for multi-field sorting. - * - * @param string $delimiter - * @throws \InvalidArgumentException - * @return self - */ - public function setDelimiter(string $delimiter): self; - - /** - * Get the delimiter used for multi-field sorting. - * - * @return string - */ - public function getDelimiter(): string; - - /** - * Set the position of null values in sorting. - * - * @param string|null $position 'first', 'last', or null for default DB behavior - * @return $this - * @throws \InvalidArgumentException - */ - public function setNullsPosition(string|null $position = null): self; + /** + * Apply sorting to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $query + * + * @return Builder + */ + public function apply(Builder $query): Builder; + + /** + * Define which fields are allowed for sorting. + * + * @param array $fields + * + * @return $this + */ + public function allow(array $fields): self; + + /** + * Map input fields to database columns. + * + * @param array $fields + * + * @return $this + */ + public function map(array $fields): self; + + /** + * Define a default sorting field and direction. + * + * @param string $field + * @param string $direction 'asc' or 'desc' + * + * @return $this + */ + public function default(string $field, string $direction = 'asc'): self; + + /** + * Add an alias (preset) for sorting. + * + * Example: + * $sortable->alias("popular", [['views', 'desc'], ['likes', 'desc']]); + * + * @param string $name + * @param array $sorting + * + * @return $this + */ + public function alias(string $name, array $sorting): self; + + /** + * Get the sorting configuration. + * + * @return array + */ + public function getConfig(): array; + + /** + * Get the allowed sorting fields. + * + * @return array + */ + public function getAllowed(): array; + + /** + * Get the default sorting field and direction. + * + * @return array|null + */ + public function getDefault(): ?array; + + /** + * Get the sorting aliases. + * + * @return array + */ + public function getAliases(): array; + + /** + * Set the request key to look for sorting parameters. + * + * @param string $key + * + * @throws \InvalidArgumentException + * + * @return $this + */ + public function setSortKey(string $key): self; + + /** + * Get the sort key used in the request. + * + * @return string + */ + public function getSortKey(): string; + + /** + * Set the delimiter for multi-field sorting. + * + * @param string $delimiter + * + * @throws \InvalidArgumentException + * + * @return self + */ + public function setDelimiter(string $delimiter): self; + + /** + * Get the delimiter used for multi-field sorting. + * + * @return string + */ + public function getDelimiter(): string; + + /** + * Set the position of null values in sorting. + * + * @param string|null $position 'first', 'last', or null for default DB behavior + * + * @throws \InvalidArgumentException + * + * @return $this + */ + public function setNullsPosition(?string $position = null): self; } diff --git a/src/Foundation/Contracts/Sorting/Invokable.php b/src/Foundation/Contracts/Sorting/Invokable.php index 6399115..16935cc 100644 --- a/src/Foundation/Contracts/Sorting/Invokable.php +++ b/src/Foundation/Contracts/Sorting/Invokable.php @@ -5,18 +5,20 @@ use Kettasoft\Filterable\Foundation\Contracts\Sortable; /** - * Interface Invokable + * Interface Invokable. * * Defines a contract for classes that can be invoked to configure sorting rules. - * @package Kettasoft\Filterable\Foundation\Contracts\Sorting + * * @link https://kettasoft.github.io/filterable/sorting#using-invokable-sort-classes */ interface Invokable { - /** - * Invoke the sorting logic. - * @param \Kettasoft\Filterable\Foundation\Contracts\Sortable $sort - * @return Sortable - */ - public function __invoke(Sortable $sort): Sortable; + /** + * Invoke the sorting logic. + * + * @param \Kettasoft\Filterable\Foundation\Contracts\Sortable $sort + * + * @return Sortable + */ + public function __invoke(Sortable $sort): Sortable; } diff --git a/src/Foundation/Events/Contracts/EventManager.php b/src/Foundation/Events/Contracts/EventManager.php index a9a0079..a21cae3 100644 --- a/src/Foundation/Events/Contracts/EventManager.php +++ b/src/Foundation/Events/Contracts/EventManager.php @@ -7,8 +7,9 @@ interface EventManager /** * Register a listener for a specific event. * - * @param string $event - * @param callable $listener + * @param string $event + * @param callable $listener + * * @return void */ public function on(string $event, callable $listener): void; @@ -16,8 +17,9 @@ public function on(string $event, callable $listener): void; /** * Register an observer for a specific filter class. * - * @param string $class - * @param callable $listener + * @param string $class + * @param callable $listener + * * @return void */ public function observe(string $class, callable $listener): void; @@ -25,14 +27,15 @@ public function observe(string $class, callable $listener): void; /** * Dispatch an event with an optional payload. * - * @param string $event - * @param mixed $payload + * @param string $event + * @param mixed $payload + * * @return void */ public function dispatch(string $event, mixed ...$payload): void; /** - * Enable a specific event behavior (e.g. logging, async dispatch, retry, etc.) + * Enable a specific event behavior (e.g. logging, async dispatch, retry, etc.). * * @return self */ @@ -55,7 +58,8 @@ public function isEnabled(): bool; /** * Get all listeners registered for a specific event. * - * @param string $event + * @param string $event + * * @return array */ public function getListeners(string $event): array; @@ -63,7 +67,8 @@ public function getListeners(string $event): array; /** * Get all observers registered for a specific class. * - * @param string $class + * @param string $class + * * @return array */ public function getObservers(string $class): array; diff --git a/src/Foundation/Events/FilterableEventManager.php b/src/Foundation/Events/FilterableEventManager.php index c1bd255..d5840eb 100644 --- a/src/Foundation/Events/FilterableEventManager.php +++ b/src/Foundation/Events/FilterableEventManager.php @@ -2,63 +2,62 @@ namespace Kettasoft\Filterable\Foundation\Events; -use Throwable; use Kettasoft\Filterable\Foundation\Events\Contracts\EventManager; +use Throwable; /** - * FilterableEventManager - * + * FilterableEventManager. + * * Centralized event management system for the Filterable package. * This class follows the Singleton pattern and manages all event listeners, * observers, and event dispatching throughout the application lifecycle. - * + * * The manager provides a clean separation of concerns by extracting event * handling logic from the main Filterable class while maintaining the * same developer-friendly API. - * - * @package Kettasoft\Filterable\Foundation\Events - * + * + * * @link https://kettasoft.github.io/filterable/features/events */ class FilterableEventManager implements EventManager { /** * The singleton instance of the event manager. - * + * * @var self|null */ protected static ?self $instance = null; /** * Global event listeners registered across all filterable instances. - * + * * @var array> */ protected array $listeners = []; /** * Filter-specific observers registered for particular filter classes. - * + * * @var array> */ protected array $observers = []; /** * Advanced event system configuration options. - * + * * @var array */ protected array $config = [ - 'logging' => false, - 'async_queue_dispatch' => false, - 'retry_mechanism' => false, + 'logging' => false, + 'async_queue_dispatch' => false, + 'retry_mechanism' => false, 'listener_priority_sorting' => false, - 'silent_failure_handling' => false, + 'silent_failure_handling' => false, ]; /** * Indicates whether the event system is globally enabled. - * + * * @var bool */ protected bool $enabled = true; @@ -75,7 +74,7 @@ protected function __construct(protected array $options = []) /** * Prevent cloning of the singleton instance. - * + * * @return void */ protected function __clone(): void @@ -85,20 +84,20 @@ protected function __clone(): void /** * Prevent unserialization of the singleton instance. - * + * * @return void */ public function __wakeup(): void { - throw new \Exception("Cannot unserialize singleton"); + throw new \Exception('Cannot unserialize singleton'); } /** * Get the singleton instance of the event manager. - * + * * This method ensures only one instance of the FilterableEventManager * exists throughout the application lifecycle. - * + * * @return self */ public static function getInstance(array $options = []): self @@ -112,8 +111,9 @@ public static function getInstance(array $options = []): self /** * Reset the singleton instance (primarily for testing). - * + * * @return void + * * @internal */ public static function resetInstance(): void @@ -123,7 +123,7 @@ public static function resetInstance(): void /** * Load configuration from Laravel's config system. - * + * * @return void */ protected function loadConfiguration(): void @@ -140,11 +140,11 @@ protected function loadConfiguration(): void /** * Register a global event listener. - * + * * This method allows you to listen to specific lifecycle events across * all filterable instances. The callback receives the filterable instance * and additional event-specific payload data. - * + * * Available events: * - filterable.initializing: When a new Filterable instance is created * - filterable.resolved: After resolving engine and request data @@ -152,11 +152,11 @@ protected function loadConfiguration(): void * - filterable.failed: If any exception occurs during apply * - filterable.finished: At the end of filtering lifecycle (finally block) * - filterable.fetched: After data retrieval operations (get, first, paginate, etc.) - * - * @param string $event The event name to listen for (e.g., 'filterable.applied') + * + * @param string $event The event name to listen for (e.g., 'filterable.applied') * @param callable $callback The callback to execute when the event fires. - * Receives ($filterable, ...$payload) as arguments. - * + * Receives ($filterable, ...$payload) as arguments. + * * @return void */ public function on(string $event, callable $callback): void @@ -170,19 +170,19 @@ public function on(string $event, callable $callback): void /** * Register an observer for a specific filter class. - * + * * Observers are called only when events are fired from instances of the * specified filter class. This is useful for filter-specific logging, * monitoring, or side effects. - * + * * The observer callback receives the event name (without the 'filterable.' prefix) * and the full payload array. - * - * @param string $filterClass The fully qualified filter class name to observe - * @param callable $callback The observer callback. Receives ($event, $payload) where - * $event is the event name (e.g., 'applied') and $payload - * is an array containing the filterable instance and other data. - * + * + * @param string $filterClass The fully qualified filter class name to observe + * @param callable $callback The observer callback. Receives ($event, $payload) where + * $event is the event name (e.g., 'applied') and $payload + * is an array containing the filterable instance and other data. + * * @return void */ public function observe(string $filterClass, callable $callback): void @@ -196,14 +196,14 @@ public function observe(string $filterClass, callable $callback): void /** * Dispatch an event and notify all registered listeners and observers. - * + * * This method is called internally at various points in the filterable lifecycle. * It handles exceptions gracefully to prevent listener failures from breaking * the filtering process. - * - * @param string $event The event name to fire (e.g., 'filterable.applied') - * @param mixed ...$payload Additional data to pass to listeners. - * + * + * @param string $event The event name to fire (e.g., 'filterable.applied') + * @param mixed ...$payload Additional data to pass to listeners. + * * @return void */ public function dispatch(string $event, mixed ...$payload): void @@ -229,10 +229,10 @@ public function dispatch(string $event, mixed ...$payload): void /** * Fire all global listeners for the given event. - * - * @param string $event The event name - * @param array $payload The event payload - * + * + * @param string $event The event name + * @param array $payload The event payload + * * @return void */ protected function fireGlobalListeners(string $event, array $payload): void @@ -255,10 +255,10 @@ protected function fireGlobalListeners(string $event, array $payload): void /** * Fire all observers for the current filter class. - * - * @param string $event The event name - * @param array $payload The event payload - * + * + * @param string $event The event name + * @param array $payload The event payload + * * @return void */ protected function fireObservers(string $event, array $payload): void @@ -279,11 +279,11 @@ protected function fireObservers(string $event, array $payload): void /** * Execute a listener callback and handle exceptions gracefully. - * + * * @param callable $listener The listener callback - * @param string $event The event name - * @param array $payload The event payload - * + * @param string $event The event name + * @param array $payload The event payload + * * @return void */ protected function executeListener(callable $listener, string $event, array $payload): void @@ -302,11 +302,11 @@ protected function executeListener(callable $listener, string $event, array $pay /** * Execute an observer callback and handle exceptions gracefully. - * - * @param callable $observer The observer callback - * @param string $eventName The event name (without prefix) - * @param array $payload The event payload - * + * + * @param callable $observer The observer callback + * @param string $eventName The event name (without prefix) + * @param array $payload The event payload + * * @return void */ protected function executeObserver(callable $observer, string $event, array ...$payload): void @@ -320,14 +320,14 @@ protected function executeObserver(callable $observer, string $event, array ...$ /** * Handle exceptions thrown by event listeners or observers. - * + * * This method logs the exception details without propagating it, * ensuring that a failing listener doesn't break the filtering process. - * + * * @param Throwable $exception The caught exception - * @param string $event The event name - * @param string $type The type of callback ('listener' or 'observer') - * + * @param string $event The event name + * @param string $type The type of callback ('listener' or 'observer') + * * @return void */ protected function handleListenerException(Throwable $exception, string $event, string $type): void @@ -336,14 +336,15 @@ protected function handleListenerException(Throwable $exception, string $event, if ($this->config['silent_failure_handling']) { // Log silently without triggering additional errors $this->logError($exception, $event, $type); + return; } // Use Laravel's logger if available, otherwise use error_log if (function_exists('logger')) { logger()->error("Filterable event {$type} failed for event '{$event}': {$exception->getMessage()}", [ - 'event' => $event, - 'type' => $type, + 'event' => $event, + 'type' => $type, 'exception' => $exception, ]); } else { @@ -358,10 +359,10 @@ protected function handleListenerException(Throwable $exception, string $event, /** * Log an event dispatch (when logging is enabled). - * - * @param string $event The event name - * @param array $payload The event payload - * + * + * @param string $event The event name + * @param array $payload The event payload + * * @return void */ protected function logEvent(string $event, array $payload): void @@ -371,7 +372,7 @@ protected function logEvent(string $event, array $payload): void } $context = [ - 'event' => $event, + 'event' => $event, 'payload_count' => count($payload), ]; @@ -384,11 +385,11 @@ protected function logEvent(string $event, array $payload): void /** * Log an error silently (used when silent_failure_handling is enabled). - * + * * @param Throwable $exception The exception - * @param string $event The event name - * @param string $type The type of callback - * + * @param string $event The event name + * @param string $type The type of callback + * * @return void */ protected function logError(Throwable $exception, string $event, string $type): void @@ -402,10 +403,10 @@ protected function logError(Throwable $exception, string $event, string $type): /** * Remove all registered event listeners and observers. - * + * * This is particularly useful in testing scenarios where you want to * ensure a clean state between tests. - * + * * @return void */ public function clear(): void @@ -416,11 +417,11 @@ public function clear(): void /** * Get all registered listeners for a specific event. - * + * * This method is primarily intended for testing and debugging purposes. - * + * * @param string $event The event name - * + * * @return array */ public function getListeners(string $event): array @@ -430,11 +431,11 @@ public function getListeners(string $event): array /** * Get all registered observers for a specific filter class. - * + * * This method is primarily intended for testing and debugging purposes. - * + * * @param string $filterClass The filter class name - * + * * @return array */ public function getObservers(string $filterClass): array @@ -444,29 +445,31 @@ public function getObservers(string $filterClass): array /** * Enable the event system globally. - * + * * @return self */ public function enable(): self { $this->enabled = true; + return $this; } /** * Disable the event system globally. - * + * * @return self */ public function disable(): self { $this->enabled = false; + return $this; } /** * Check if the event system is enabled. - * + * * @return bool */ public function isEnabled(): bool @@ -476,19 +479,19 @@ public function isEnabled(): bool /** * Enable a specific advanced configuration option. - * + * * Available options: * - logging: Log all event dispatches * - async_queue_dispatch: Dispatch listeners to queue (future implementation) * - retry_mechanism: Retry failed listeners (future implementation) * - listener_priority_sorting: Sort listeners by priority (future implementation) * - silent_failure_handling: Suppress error logs for failed listeners - * + * * @param string $option The configuration option to enable - * - * @return self - * + * * @throws \InvalidArgumentException If the option doesn't exist + * + * @return self */ public function enableOption(string $option): self { @@ -497,17 +500,18 @@ public function enableOption(string $option): self } $this->config[$option] = true; + return $this; } /** * Disable a specific advanced configuration option. - * + * * @param string $option The configuration option to disable - * - * @return self - * + * * @throws \InvalidArgumentException If the option doesn't exist + * + * @return self */ public function disableOption(string $option): self { @@ -516,14 +520,15 @@ public function disableOption(string $option): self } $this->config[$option] = false; + return $this; } /** * Check if a specific configuration option is enabled. - * + * * @param string $option The configuration option to check - * + * * @return bool */ public function isOptionEnabled(string $option): bool @@ -533,7 +538,7 @@ public function isOptionEnabled(string $option): bool /** * Get all configuration options. - * + * * @return array */ public function getConfig(): array @@ -543,9 +548,9 @@ public function getConfig(): array /** * Set multiple configuration options at once. - * + * * @param array $config Configuration options to set - * + * * @return self */ public function setConfig(array $config): self diff --git a/src/Foundation/Events/FilterableState.php b/src/Foundation/Events/FilterableState.php index bb90086..befb552 100644 --- a/src/Foundation/Events/FilterableState.php +++ b/src/Foundation/Events/FilterableState.php @@ -6,7 +6,7 @@ /** * Class representing the status of a filtered event. - * + * * @implements \Stringable * @implements \Kettasoft\Filterable\Contracts\Matchable */ @@ -23,6 +23,7 @@ class FilterableState implements \Stringable, Matchable * Create a new FilteredEventStatus instance. * * @param string $status + * * @return void */ public function __construct(string $status) @@ -34,6 +35,7 @@ public function __construct(string $status) * Check if the filtered event has the given status. * * @param mixed $status + * * @return bool */ public function is(mixed $status): bool diff --git a/src/Foundation/FilterableSettings.php b/src/Foundation/FilterableSettings.php index 3677d87..d7c2ef6 100644 --- a/src/Foundation/FilterableSettings.php +++ b/src/Foundation/FilterableSettings.php @@ -3,41 +3,44 @@ namespace Kettasoft\Filterable\Foundation; /** - * Class FilterableSettings + * Class FilterableSettings. * * This class holds the settings for filterable fields, relations, operators, sanitizers, and field maps. * It is used to initialize and manage the filterable settings in a structured way. */ readonly class FilterableSettings { - /** - * FilterableSettings constructor. - * @param array $fields - * @param array $relations - * @param array $operators - * @param array $sanitizers - * @param array $fieldMaps - */ - public function __construct( - public array $fields, - public array $relations, - public array $operators, - public array $sanitizers, - public array $fieldMaps - ) {} + /** + * FilterableSettings constructor. + * + * @param array $fields + * @param array $relations + * @param array $operators + * @param array $sanitizers + * @param array $fieldMaps + */ + public function __construct( + public array $fields, + public array $relations, + public array $operators, + public array $sanitizers, + public array $fieldMaps + ) { + } - /** - * Initialize the FilterableSettings with the provided parameters. - * - * @param array $fields - * @param array $relations - * @param array $operators - * @param array $sanitizers - * @param array $fieldMaps - * @return FilterableSettings - */ - public static function init(array $fields, array $relations, array $operators, array $sanitizers, array $fieldMaps): FilterableSettings - { - return new self($fields, $relations, $operators, $sanitizers, $fieldMaps); - } + /** + * Initialize the FilterableSettings with the provided parameters. + * + * @param array $fields + * @param array $relations + * @param array $operators + * @param array $sanitizers + * @param array $fieldMaps + * + * @return FilterableSettings + */ + public static function init(array $fields, array $relations, array $operators, array $sanitizers, array $fieldMaps): FilterableSettings + { + return new self($fields, $relations, $operators, $sanitizers, $fieldMaps); + } } diff --git a/src/Foundation/Invoker.php b/src/Foundation/Invoker.php index 04f8152..ebd231b 100644 --- a/src/Foundation/Invoker.php +++ b/src/Foundation/Invoker.php @@ -3,382 +3,402 @@ namespace Kettasoft\Filterable\Foundation; use Closure; -use Serializable; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\App; +use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Query\Builder; -use Illuminate\Support\Traits\ForwardsCalls; -use function Opis\Closure\{serialize, unserialize}; use Illuminate\Database\Query\Builder as QueryBuilder; -use Kettasoft\Filterable\Foundation\Profiler\Profiler; -use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; -use Kettasoft\Filterable\Foundation\Contracts\HasDynamicCalls; - -use Kettasoft\Filterable\Foundation\Traits\HandleFluentReturn; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Traits\ForwardsCalls; use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; +use Kettasoft\Filterable\Foundation\Contracts\HasDynamicCalls; use Kettasoft\Filterable\Foundation\Contracts\QueryBuilderInterface; +use Kettasoft\Filterable\Foundation\Profiler\Profiler; +use Kettasoft\Filterable\Foundation\Traits\HandleFluentReturn; +use Serializable; + +use function Opis\Closure\{serialize, unserialize}; /** - * Class Invoker + * Class Invoker. * * The Invoker class acts as a wrapper around a query builder instance, * providing hooks for before/after execution logic, error handling, and * flexible runtime behavior such as deferred execution via Laravel jobs. - * + * * @link https://kettasoft.github.io/filterable/execution/invoker */ class Invoker implements QueryBuilderInterface, Serializable, HasDynamicCalls { - use ForwardsCalls, - HandleFluentReturn; - - /** - * Callback to be executed before the query is run. - * - * @var Closure|null - */ - protected $beforeCallback; - - /** - * Callback to be executed after the query is run. - * - * @var Closure|null - */ - protected $afterCallback; - - /** - * Callback to be executed in case of error during query execution. - * - * @var Closure|null - */ - protected $errorCallback; - - /** - * Whether caching is enabled for this invoker - * - * @var bool - */ - protected bool $cachingEnabled = false; - - /** - * Cache key - * - * @var string|null - */ - protected ?string $cacheKey = null; - - /** - * Cache TTL - * - * @var \DateTimeInterface|int|null - */ - protected \DateTimeInterface|int|null $cacheTtl = null; - - /** - * Cache tags - * - * @var array - */ - protected array $cacheTags = []; - - /** - * Whether to cache forever - * - * @var bool - */ - protected bool $cacheForever = false; - - /** - * Create a new Invoker instance. - * - * @param QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder - */ - public function __construct(protected QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder) - { - if (config('filterable.profiler.enabled', false)) { - app(Profiler::class)->start(); + use ForwardsCalls; + use HandleFluentReturn; + + /** + * Callback to be executed before the query is run. + * + * @var Closure|null + */ + protected $beforeCallback; + + /** + * Callback to be executed after the query is run. + * + * @var Closure|null + */ + protected $afterCallback; + + /** + * Callback to be executed in case of error during query execution. + * + * @var Closure|null + */ + protected $errorCallback; + + /** + * Whether caching is enabled for this invoker. + * + * @var bool + */ + protected bool $cachingEnabled = false; + + /** + * Cache key. + * + * @var string|null + */ + protected ?string $cacheKey = null; + + /** + * Cache TTL. + * + * @var \DateTimeInterface|int|null + */ + protected \DateTimeInterface|int|null $cacheTtl = null; + + /** + * Cache tags. + * + * @var array + */ + protected array $cacheTags = []; + + /** + * Whether to cache forever. + * + * @var bool + */ + protected bool $cacheForever = false; + + /** + * Create a new Invoker instance. + * + * @param QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder + */ + public function __construct(protected QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder) + { + if (config('filterable.profiler.enabled', false)) { + app(Profiler::class)->start(); + } + } + + /** + * Instantiate a new Invoker object using a builder. + * + * @param \Kettasoft\Filterable\Foundation\Contracts\QueryBuilderInterface $builder + * + * @return Invoker + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#public-methods + */ + public static function init(QueryBuilderInterface $builder) + { + return new self($builder); + } + + /** + * Execute a callback if the given condition is true. + * + * @param bool $condition + * @param callable $callback + * + * @return static + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#when + */ + public function when(bool $condition, callable $callback): static + { + if ($condition) { + $callback($this); + } + + return $this; + } + + /** + * Execute a callback if the given condition is false. + * + * @param bool $condition + * @param callable $callback + * + * @return static + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#unless + */ + public function unless(bool $condition, callable $callback): static + { + return $this->when(!$condition, $callback); + } + + /** + * Set the callback to be called before execution. + * + * @param Closure $callback + * + * @return $this + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#beforeExecute + */ + public function beforeExecute(Closure $callback): static + { + $this->beforeCallback = $callback; + + return $this; } - } - - /** - * Instantiate a new Invoker object using a builder. - * @param \Kettasoft\Filterable\Foundation\Contracts\QueryBuilderInterface $builder - * @return Invoker - * @link https://kettasoft.github.io/filterable/execution/invoker.html#public-methods - */ - public static function init(QueryBuilderInterface $builder) - { - return new self($builder); - } - - /** - * Execute a callback if the given condition is true. - * - * @param bool $condition - * @param callable $callback - * @return static - * @link https://kettasoft.github.io/filterable/execution/invoker.html#when - */ - public function when(bool $condition, callable $callback): static - { - if ($condition) { - $callback($this); + + /** + * Set the callback to be called after execution. + * + * @param Closure $callback + * + * @return $this + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#afterExecute + */ + public function afterExecute(Closure $callback): static + { + $this->afterCallback = $callback; + + return $this; } - return $this; - } - - /** - * Execute a callback if the given condition is false. - * - * @param bool $condition - * @param callable $callback - * @return static - * @link https://kettasoft.github.io/filterable/execution/invoker.html#unless - */ - public function unless(bool $condition, callable $callback): static - { - return $this->when(!$condition, $callback); - } - - /** - * Set the callback to be called before execution. - * - * @param Closure $callback - * @return $this - * @link https://kettasoft.github.io/filterable/execution/invoker.html#beforeExecute - */ - public function beforeExecute(Closure $callback): static - { - $this->beforeCallback = $callback; - - return $this; - } - - /** - * Set the callback to be called after execution. - * - * @param Closure $callback - * @return $this - * @link https://kettasoft.github.io/filterable/execution/invoker.html#afterExecute - */ - public function afterExecute(Closure $callback): static - { - $this->afterCallback = $callback; - - return $this; - } - - /** - * Set the callback to be called after execution. - * - * @param Closure $callback - * @return $this - * @link https://kettasoft.github.io/filterable/execution/invoker.html#onError - */ - public function onError(Closure $callback) - { - $this->errorCallback = $callback; - - return $this; - } - - /** - * Get the underlying query builder instance. - * - * @return Builder|EloquentBuilder|QueryBuilderInterface - */ - public function getBuilder(): EloquentBuilder|Builder|QueryBuilderInterface - { - return $this->builder; - } - - /** - * Enable caching for this invoker - * - * @param string $cacheKey - * @param DateTimeInterface|int|null $ttl - * @param array $tags - * @param bool $forever - * @return self - */ - public function enableCaching( - string $cacheKey, - \DateTimeInterface|int|null $ttl = null, - array $tags = [], - bool $forever = false - ): self { - $this->cachingEnabled = true; - $this->cacheKey = $cacheKey; - $this->cacheTtl = $ttl; - $this->cacheTags = $tags; - $this->cacheForever = $forever; - - return $this; - } - - /** - * Check if this is a terminal method that should fetch data - * - * @param string $method - * @return bool - */ - protected function isTerminalMethod(string $method): bool - { - return in_array($method, [ - 'get', - 'first', - 'find', - 'findOrFail', - 'sole', - 'value', - 'pluck', - 'implode', - 'exists', - 'doesntExist', - 'count', - 'min', - 'max', - 'sum', - 'avg', - 'average', - 'paginate', - 'simplePaginate', - 'cursorPaginate', - 'chunk', - 'chunkById', - 'each', - 'eachById', - 'lazy', - 'lazyById', - 'lazyByIdDesc' - ]); - } - - /** - * Dispatch the query execution as a Laravel job. - * - * @param string $jobClass - * @param array $jobData - * @param string|null $queue - * @return mixed - * - * @throws \InvalidArgumentException - * @link https://kettasoft.github.io/filterable/execution/invoker.html#asJob - */ - public function asJob(string $jobClass, array $jobData = [], string|null $queue = null): mixed - { - if (!class_exists($jobClass)) { - throw new \InvalidArgumentException("Job class [$jobClass] does not exist."); + /** + * Set the callback to be called after execution. + * + * @param Closure $callback + * + * @return $this + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#onError + */ + public function onError(Closure $callback) + { + $this->errorCallback = $callback; + + return $this; } - $job = new $jobClass(array_merge($jobData, [ - 'invoker' => $this, - ])); + /** + * Get the underlying query builder instance. + * + * @return Builder|EloquentBuilder|QueryBuilderInterface + */ + public function getBuilder(): EloquentBuilder|Builder|QueryBuilderInterface + { + return $this->builder; + } - if ($queue) { - return dispatch($job)->onQueue($queue); + /** + * Enable caching for this invoker. + * + * @param string $cacheKey + * @param DateTimeInterface|int|null $ttl + * @param array $tags + * @param bool $forever + * + * @return self + */ + public function enableCaching( + string $cacheKey, + \DateTimeInterface|int|null $ttl = null, + array $tags = [], + bool $forever = false + ): self { + $this->cachingEnabled = true; + $this->cacheKey = $cacheKey; + $this->cacheTtl = $ttl; + $this->cacheTags = $tags; + $this->cacheForever = $forever; + + return $this; } - return dispatch($job); - } - - /** - * Serialize the Invoker instance. - * - * @return string - */ - public function serialize(): string - { - return serialize([ - 'builder_sql' => $this->builder->toSql(), - 'builder_bindings' => $this->builder->getBindings(), - 'beforeCallback' => $this->beforeCallback ? serialize($this->beforeCallback) : null, - 'afterCallback' => $this->afterCallback ? serialize($this->afterCallback) : null, - 'errorCallback' => $this->errorCallback ? serialize($this->errorCallback) : null - ]); - } - - /** - * Unserialize the Invoker instance. - * - * @param string $data - * @return void - */ - public function unserialize($data): void - { - $unserialized = unserialize($data); - $connection = App::make('db')->connection(); - - $this->builder = $connection->table(DB::raw("({$unserialized['builder_sql']}) as t")); - - $this->builder->setBindings($unserialized['builder_bindings']); - - $this->beforeCallback = $unserialized['beforeCallback']; - $this->afterCallback = $unserialized['afterCallback']; - $this->errorCallback = $unserialized['errorCallback']; - } - - /** - * Handles dynamic calls to the builder, and tracks execution time - * for terminal methods only. - * - * @param string $method - * @param array $args - * @return mixed - */ - public function __call($method, $args) - { - if (is_callable($callback = $this->beforeCallback)) { - call_user_func($callback, $this->builder); + /** + * Check if this is a terminal method that should fetch data. + * + * @param string $method + * + * @return bool + */ + protected function isTerminalMethod(string $method): bool + { + return in_array($method, [ + 'get', + 'first', + 'find', + 'findOrFail', + 'sole', + 'value', + 'pluck', + 'implode', + 'exists', + 'doesntExist', + 'count', + 'min', + 'max', + 'sum', + 'avg', + 'average', + 'paginate', + 'simplePaginate', + 'cursorPaginate', + 'chunk', + 'chunkById', + 'each', + 'eachById', + 'lazy', + 'lazyById', + 'lazyByIdDesc', + ]); } - try { - // Check if caching is enabled and this is a terminal method - if ($this->cachingEnabled && $this->isTerminalMethod($method)) { - $result = $this->executionWithCache($method, $args); - } else { - $result = $this->handleFluentReturn($method, $args); - } - - if (is_callable($this->afterCallback)) { - return call_user_func($this->afterCallback, $result); - } - - return $result; - } catch (\Throwable $th) { - if (is_callable($callback = $this->errorCallback)) { - return call_user_func($callback, $this, $th); - } - - throw $th; + /** + * Dispatch the query execution as a Laravel job. + * + * @param string $jobClass + * @param array $jobData + * @param string|null $queue + * + * @throws \InvalidArgumentException + * + * @return mixed + * + * @link https://kettasoft.github.io/filterable/execution/invoker.html#asJob + */ + public function asJob(string $jobClass, array $jobData = [], ?string $queue = null): mixed + { + if (!class_exists($jobClass)) { + throw new \InvalidArgumentException("Job class [$jobClass] does not exist."); + } + + $job = new $jobClass(array_merge($jobData, [ + 'invoker' => $this, + ])); + + if ($queue) { + return dispatch($job)->onQueue($queue); + } + + return dispatch($job); } - } - - /** - * Execute a terminal method with caching - * - * @param string $method - * @param array $args - * @return mixed - */ - protected function executionWithCache(string $method, array $args): mixed - { - $manager = app(FilterableCacheManager::class) - ->withTags($this->cacheTags); - - // Use the pre-generated cache key and append method+args for uniqueness - $methodArgsHash = md5(json_encode(['method' => $method, 'args' => $args])); - $fullCacheKey = $this->cacheKey . ':' . $method . ':' . $methodArgsHash; - - if ($this->cacheForever) { - return $manager->rememberForever($fullCacheKey, function () use ($method, $args) { - return $this->forwardCallTo($this->builder, $method, $args); - }); + + /** + * Serialize the Invoker instance. + * + * @return string + */ + public function serialize(): string + { + return serialize([ + 'builder_sql' => $this->builder->toSql(), + 'builder_bindings' => $this->builder->getBindings(), + 'beforeCallback' => $this->beforeCallback ? serialize($this->beforeCallback) : null, + 'afterCallback' => $this->afterCallback ? serialize($this->afterCallback) : null, + 'errorCallback' => $this->errorCallback ? serialize($this->errorCallback) : null, + ]); } - return $manager->remember($fullCacheKey, $this->cacheTtl, function () use ($method, $args) { - return $this->forwardCallTo($this->builder, $method, $args); - }); - } + /** + * Unserialize the Invoker instance. + * + * @param string $data + * + * @return void + */ + public function unserialize($data): void + { + $unserialized = unserialize($data); + $connection = App::make('db')->connection(); + + $this->builder = $connection->table(DB::raw("({$unserialized['builder_sql']}) as t")); + + $this->builder->setBindings($unserialized['builder_bindings']); + + $this->beforeCallback = $unserialized['beforeCallback']; + $this->afterCallback = $unserialized['afterCallback']; + $this->errorCallback = $unserialized['errorCallback']; + } + + /** + * Handles dynamic calls to the builder, and tracks execution time + * for terminal methods only. + * + * @param string $method + * @param array $args + * + * @return mixed + */ + public function __call($method, $args) + { + if (is_callable($callback = $this->beforeCallback)) { + call_user_func($callback, $this->builder); + } + + try { + // Check if caching is enabled and this is a terminal method + if ($this->cachingEnabled && $this->isTerminalMethod($method)) { + $result = $this->executionWithCache($method, $args); + } else { + $result = $this->handleFluentReturn($method, $args); + } + + if (is_callable($this->afterCallback)) { + return call_user_func($this->afterCallback, $result); + } + + return $result; + } catch (\Throwable $th) { + if (is_callable($callback = $this->errorCallback)) { + return call_user_func($callback, $this, $th); + } + + throw $th; + } + } + + /** + * Execute a terminal method with caching. + * + * @param string $method + * @param array $args + * + * @return mixed + */ + protected function executionWithCache(string $method, array $args): mixed + { + $manager = app(FilterableCacheManager::class) + ->withTags($this->cacheTags); + + // Use the pre-generated cache key and append method+args for uniqueness + $methodArgsHash = md5(json_encode(['method' => $method, 'args' => $args])); + $fullCacheKey = $this->cacheKey.':'.$method.':'.$methodArgsHash; + + if ($this->cacheForever) { + return $manager->rememberForever($fullCacheKey, function () use ($method, $args) { + return $this->forwardCallTo($this->builder, $method, $args); + }); + } + + return $manager->remember($fullCacheKey, $this->cacheTtl, function () use ($method, $args) { + return $this->forwardCallTo($this->builder, $method, $args); + }); + } } diff --git a/src/Foundation/Profiler/Contracts/ProfilerStorageContract.php b/src/Foundation/Profiler/Contracts/ProfilerStorageContract.php index ad282b9..67c572b 100644 --- a/src/Foundation/Profiler/Contracts/ProfilerStorageContract.php +++ b/src/Foundation/Profiler/Contracts/ProfilerStorageContract.php @@ -9,11 +9,12 @@ */ interface ProfilerStorageContract { - /** - * Store the profiler data. - * - * @param array $data - * @return void - */ - public function store(mixed $data): void; + /** + * Store the profiler data. + * + * @param array $data + * + * @return void + */ + public function store(mixed $data): void; } diff --git a/src/Foundation/Profiler/Events/ProfilerEventDispatcher.php b/src/Foundation/Profiler/Events/ProfilerEventDispatcher.php index 45683e6..2537b70 100644 --- a/src/Foundation/Profiler/Events/ProfilerEventDispatcher.php +++ b/src/Foundation/Profiler/Events/ProfilerEventDispatcher.php @@ -4,58 +4,60 @@ class ProfilerEventDispatcher { - /** - * @var array - */ - protected array $listeners = []; + /** + * @var array + */ + protected array $listeners = []; - /** - * Event name for slow queries. - * - * @var string - */ - public const SLOW_QUERY_EVENT_NAME = 'onSlowQuery'; + /** + * Event name for slow queries. + * + * @var string + */ + public const SLOW_QUERY_EVENT_NAME = 'onSlowQuery'; - /** - * Event name for duplicate queries. - * - * @var string - */ - public const DUBLICATE_QUERY_EVENT_NAME = 'onDuplicateQuery'; + /** + * Event name for duplicate queries. + * + * @var string + */ + public const DUBLICATE_QUERY_EVENT_NAME = 'onDuplicateQuery'; - /** - * Register an event listener. - * - * @param string $event - * @param callable $listener - * @return void - */ - public function listen(string $event, callable $listener): void - { - $this->listeners[$event][] = $listener; - } + /** + * Register an event listener. + * + * @param string $event + * @param callable $listener + * + * @return void + */ + public function listen(string $event, callable $listener): void + { + $this->listeners[$event][] = $listener; + } - /** - * Dispatch an event. - * - * @param string $event - * @param mixed $payload - * @return void - */ - public function dispatch(string $event, mixed $payload = null): void - { - foreach ($this->listeners[$event] ?? [] as $listener) { - $listener($payload); + /** + * Dispatch an event. + * + * @param string $event + * @param mixed $payload + * + * @return void + */ + public function dispatch(string $event, mixed $payload = null): void + { + foreach ($this->listeners[$event] ?? [] as $listener) { + $listener($payload); + } } - } - /** - * Flush all listeners. - * - * @return void - */ - public function flush(): void - { - $this->listeners = []; - } + /** + * Flush all listeners. + * + * @return void + */ + public function flush(): void + { + $this->listeners = []; + } } diff --git a/src/Foundation/Profiler/Profiler.php b/src/Foundation/Profiler/Profiler.php index 61ba79e..2a48367 100644 --- a/src/Foundation/Profiler/Profiler.php +++ b/src/Foundation/Profiler/Profiler.php @@ -2,12 +2,12 @@ namespace Kettasoft\Filterable\Foundation\Profiler; -use Illuminate\Support\Facades\DB; -use Illuminate\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Events\QueryExecuted; -use Kettasoft\Filterable\Foundation\Profiler\Events\ProfilerEventDispatcher; +use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\DB; use Kettasoft\Filterable\Foundation\Profiler\Contracts\ProfilerStorageContract; +use Kettasoft\Filterable\Foundation\Profiler\Events\ProfilerEventDispatcher; use Kettasoft\Filterable\Foundation\Profiler\Traits\HasProfilerEventDispatcher; /** @@ -16,156 +16,156 @@ * This class listens to database query events and collects information * about executed queries, including SQL, bindings, execution time, and * provides methods to retrieve statistics about the queries. - * - * @package Kettasoft\Filterable\Foundation\Profiler */ class Profiler { - use HasProfilerEventDispatcher; - - /** - * Array to hold collected queries. - * - * @var array - */ - protected array $queries = []; - - /** - * The Laravel application instance. - * - * @var \Illuminate\Foundation\Application - */ - protected Application $app; - - /** - * The current HTTP request instance. - * - * @var \Illuminate\Http\Request - */ - protected $request; - - /** - * Create a new Profiler instance. - * - * @param ProfilerStorageContract $storage Storage implementation for profiler data. - */ - public function __construct(protected ProfilerStorageContract $storage, protected Builder $builder) - { - $this->app = app(); - $this->request = app('request'); - } - - /** - * Start the profiler by listening to database query events. - * - * @return void - */ - public function start() - { - DB::listen(fn(QueryExecuted $query) => $this->addQuery($query)); - } - - /** - * Add a query to the profiler's collection. - * - * @param QueryExecuted $query - * @return void - */ - protected function addQuery(QueryExecuted $query): void - { - $queryData = [ - 'sql' => $query->sql, - 'bindings' => $query->bindings, - 'time' => $query->time, - ]; - - $this->queries[] = $queryData; - - if ($query->time > config('filterable.profiler.slow_query_threshold', 100)) { - $this->dispatch(ProfilerEventDispatcher::SLOW_QUERY_EVENT_NAME, $queryData); + use HasProfilerEventDispatcher; + + /** + * Array to hold collected queries. + * + * @var array + */ + protected array $queries = []; + + /** + * The Laravel application instance. + * + * @var \Illuminate\Foundation\Application + */ + protected Application $app; + + /** + * The current HTTP request instance. + * + * @var \Illuminate\Http\Request + */ + protected $request; + + /** + * Create a new Profiler instance. + * + * @param ProfilerStorageContract $storage Storage implementation for profiler data. + */ + public function __construct(protected ProfilerStorageContract $storage, protected Builder $builder) + { + $this->app = app(); + $this->request = app('request'); } - // check for duplicate query - $duplicates = $this->getDuplicates(); - foreach ($duplicates as $dup) { - if ($dup['sql'] === $query->sql) { - $this->dispatch(ProfilerEventDispatcher::DUBLICATE_QUERY_EVENT_NAME, $dup); - break; - } + /** + * Start the profiler by listening to database query events. + * + * @return void + */ + public function start() + { + DB::listen(fn (QueryExecuted $query) => $this->addQuery($query)); } - } - - /** - * Get all collected queries. - * - * @return array - */ - public function getQueries(): array - { - return $this->queries; - } - - /** - * Get duplicated queries (same SQL executed multiple times). - * - * @return array - */ - public function getDuplicates(): array - { - $counts = []; - - foreach ($this->queries as $query) { - $key = md5($query['sql']); - $counts[$key]['sql'] = $query['sql']; - $counts[$key]['count'] = ($counts[$key]['count'] ?? 0) + 1; - $counts[$key]['total_time'] = ($counts[$key]['total_time'] ?? 0) + $query['time']; + + /** + * Add a query to the profiler's collection. + * + * @param QueryExecuted $query + * + * @return void + */ + protected function addQuery(QueryExecuted $query): void + { + $queryData = [ + 'sql' => $query->sql, + 'bindings' => $query->bindings, + 'time' => $query->time, + ]; + + $this->queries[] = $queryData; + + if ($query->time > config('filterable.profiler.slow_query_threshold', 100)) { + $this->dispatch(ProfilerEventDispatcher::SLOW_QUERY_EVENT_NAME, $queryData); + } + + // check for duplicate query + $duplicates = $this->getDuplicates(); + foreach ($duplicates as $dup) { + if ($dup['sql'] === $query->sql) { + $this->dispatch(ProfilerEventDispatcher::DUBLICATE_QUERY_EVENT_NAME, $dup); + break; + } + } } - return array_values(array_filter($counts, fn($q) => $q['count'] > 1)); - } - - /** - * Get only slow queries over a certain threshold. - * - * @param float $threshold - * @return array - */ - public function getSlowQueries(float $threshold = 100): array - { - return array_filter($this->queries, fn($q) => $q['time'] > $threshold); - } - - /** - * Export profiler report as structured array. - * - * @return array - */ - public function toExportArray(): array - { - return [ - 'duplicates' => $this->getDuplicates(), - 'slow_queries' => $this->getSlowQueries(), - 'queries' => $this->queries, - 'total_queries' => count($this->queries), - 'total_time' => array_sum(array_column($this->queries, 'time')), - 'total_memory' => memory_get_usage(true), - 'connection_name' => $this->builder->getConnection()->getDatabaseName(), - 'executed_at' => now()->toDateTimeString(), - 'model_class' => $this->builder->getModel() ? get_class($this->builder->getModel()) : null, - 'request_method' => $this->request->method(), - 'request_uri' => $this->request->getRequestUri(), - 'request_query' => $this->request->query(), - 'request_body' => $this->request->all() - ]; - } - - /** - * Destructor to store profiler data when the object is destroyed. - * - * @return void - */ - public function __destruct() - { - $this->storage->store($this->toExportArray()); - } + /** + * Get all collected queries. + * + * @return array + */ + public function getQueries(): array + { + return $this->queries; + } + + /** + * Get duplicated queries (same SQL executed multiple times). + * + * @return array + */ + public function getDuplicates(): array + { + $counts = []; + + foreach ($this->queries as $query) { + $key = md5($query['sql']); + $counts[$key]['sql'] = $query['sql']; + $counts[$key]['count'] = ($counts[$key]['count'] ?? 0) + 1; + $counts[$key]['total_time'] = ($counts[$key]['total_time'] ?? 0) + $query['time']; + } + + return array_values(array_filter($counts, fn ($q) => $q['count'] > 1)); + } + + /** + * Get only slow queries over a certain threshold. + * + * @param float $threshold + * + * @return array + */ + public function getSlowQueries(float $threshold = 100): array + { + return array_filter($this->queries, fn ($q) => $q['time'] > $threshold); + } + + /** + * Export profiler report as structured array. + * + * @return array + */ + public function toExportArray(): array + { + return [ + 'duplicates' => $this->getDuplicates(), + 'slow_queries' => $this->getSlowQueries(), + 'queries' => $this->queries, + 'total_queries' => count($this->queries), + 'total_time' => array_sum(array_column($this->queries, 'time')), + 'total_memory' => memory_get_usage(true), + 'connection_name' => $this->builder->getConnection()->getDatabaseName(), + 'executed_at' => now()->toDateTimeString(), + 'model_class' => $this->builder->getModel() ? get_class($this->builder->getModel()) : null, + 'request_method' => $this->request->method(), + 'request_uri' => $this->request->getRequestUri(), + 'request_query' => $this->request->query(), + 'request_body' => $this->request->all(), + ]; + } + + /** + * Destructor to store profiler data when the object is destroyed. + * + * @return void + */ + public function __destruct() + { + $this->storage->store($this->toExportArray()); + } } diff --git a/src/Foundation/Profiler/Storage/DatabaseProfilerStorage.php b/src/Foundation/Profiler/Storage/DatabaseProfilerStorage.php index fd7439c..5a859df 100644 --- a/src/Foundation/Profiler/Storage/DatabaseProfilerStorage.php +++ b/src/Foundation/Profiler/Storage/DatabaseProfilerStorage.php @@ -7,14 +7,15 @@ class DatabaseProfilerStorage implements ProfilerStorageContract { - /** - * Store the profiler data. - * - * @param array $data - * @return void - */ - public function store(mixed $data): void - { - DB::table('users')->insert($data); - } + /** + * Store the profiler data. + * + * @param array $data + * + * @return void + */ + public function store(mixed $data): void + { + DB::table('users')->insert($data); + } } diff --git a/src/Foundation/Profiler/Storage/FileProfilerStorage.php b/src/Foundation/Profiler/Storage/FileProfilerStorage.php index 47b2a18..46ba130 100644 --- a/src/Foundation/Profiler/Storage/FileProfilerStorage.php +++ b/src/Foundation/Profiler/Storage/FileProfilerStorage.php @@ -6,24 +6,25 @@ class FileProfilerStorage implements ProfilerStorageContract { - /** - * Store the profiler data. - * - * @param array $data - * @return void - */ - public function store(mixed $data): void - { - $data = array_merge($data, [ - // Additional data can be added here if needed - ]); + /** + * Store the profiler data. + * + * @param array $data + * + * @return void + */ + public function store(mixed $data): void + { + $data = array_merge($data, [ + // Additional data can be added here if needed + ]); - try { - $filePath = storage_path('logs/filterable-profiler.log'); - file_put_contents($filePath, json_encode($data) . PHP_EOL, FILE_APPEND); - } catch (\Exception $e) { - // Handle any exceptions that may occur during file writing - error_log('Profiler storage error: ' . $e->getMessage()); + try { + $filePath = storage_path('logs/filterable-profiler.log'); + file_put_contents($filePath, json_encode($data).PHP_EOL, FILE_APPEND); + } catch (\Exception $e) { + // Handle any exceptions that may occur during file writing + error_log('Profiler storage error: '.$e->getMessage()); + } } - } } diff --git a/src/Foundation/Profiler/Traits/HasProfilerEventDispatcher.php b/src/Foundation/Profiler/Traits/HasProfilerEventDispatcher.php index 2ea6ee8..cb89c9f 100644 --- a/src/Foundation/Profiler/Traits/HasProfilerEventDispatcher.php +++ b/src/Foundation/Profiler/Traits/HasProfilerEventDispatcher.php @@ -6,46 +6,48 @@ trait HasProfilerEventDispatcher { - /** - * @var ProfilerEventDispatcher - */ - protected static ?ProfilerEventDispatcher $dispatcher = null; + /** + * @var ProfilerEventDispatcher + */ + protected static ?ProfilerEventDispatcher $dispatcher = null; - /** - * Get the current ProfilerEventDispatcher instance. - * - * @return ProfilerEventDispatcher - */ - public static function dispatcher(): ProfilerEventDispatcher - { - if (! static::$dispatcher) { - static::$dispatcher = new ProfilerEventDispatcher(); - } + /** + * Get the current ProfilerEventDispatcher instance. + * + * @return ProfilerEventDispatcher + */ + public static function dispatcher(): ProfilerEventDispatcher + { + if (!static::$dispatcher) { + static::$dispatcher = new ProfilerEventDispatcher(); + } - return static::$dispatcher; - } + return static::$dispatcher; + } - /** - * Register an event listener (static API). - * - * @param string $event - * @param callable $callback - * @return void - */ - public static function listen(string $event, callable $callback): void - { - static::dispatcher()->listen($event, $callback); - } + /** + * Register an event listener (static API). + * + * @param string $event + * @param callable $callback + * + * @return void + */ + public static function listen(string $event, callable $callback): void + { + static::dispatcher()->listen($event, $callback); + } - /** - * Dispatch an event (used internally). - * - * @param string $event - * @param mixed $payload - * @return void - */ - protected function dispatch(string $event, mixed $payload = null): void - { - static::dispatcher()->dispatch($event, $payload); - } + /** + * Dispatch an event (used internally). + * + * @param string $event + * @param mixed $payload + * + * @return void + */ + protected function dispatch(string $event, mixed $payload = null): void + { + static::dispatcher()->dispatch($event, $payload); + } } diff --git a/src/Foundation/Resources.php b/src/Foundation/Resources.php index 50bb1d1..1e298bc 100644 --- a/src/Foundation/Resources.php +++ b/src/Foundation/Resources.php @@ -10,68 +10,75 @@ use Kettasoft\Filterable\Foundation\Bags\SanitizerBag; /** - * Class Resources + * Class Resources. * * This class holds various bags for fields, relations, operators, sanitizers, and field maps. * It is used to manage and access filterable resources in a structured way. */ class Resources { - /** - * Field bag instance. - * @var FieldBag - */ - public Bag $fields; + /** + * Field bag instance. + * + * @var FieldBag + */ + public Bag $fields; - /** - * Relation bag instance. - * @var RelationBag - */ - public Bag $relations; + /** + * Relation bag instance. + * + * @var RelationBag + */ + public Bag $relations; - /** - * Operator bag instance. - * @var OperatorBag - */ - public Bag $operators; + /** + * Operator bag instance. + * + * @var OperatorBag + */ + public Bag $operators; - /** - * Sanitizers bag instance. - * @var SanitizerBag - */ - public Bag $sanitizers; + /** + * Sanitizers bag instance. + * + * @var SanitizerBag + */ + public Bag $sanitizers; - /** - * FieldMapBag bag instance. - * @var FieldMapBag - */ - public Bag $fieldMap; + /** + * FieldMapBag bag instance. + * + * @var FieldMapBag + */ + public Bag $fieldMap; - /** - * Resources constructor. - * - * Initializes the bags with the provided settings. - * - * @param FilterableSettings $settings - */ - public function __construct(FilterableSettings $settings) - { - $this->fields = new FieldBag($settings->fields); - $this->relations = new RelationBag($settings->relations); - $this->operators = new OperatorBag($settings->operators); - $this->sanitizers = new SanitizerBag($settings->sanitizers); - $this->fieldMap = new SanitizerBag($settings->fieldMaps); - } + /** + * Resources constructor. + * + * Initializes the bags with the provided settings. + * + * @param FilterableSettings $settings + */ + public function __construct(FilterableSettings $settings) + { + $this->fields = new FieldBag($settings->fields); + $this->relations = new RelationBag($settings->relations); + $this->operators = new OperatorBag($settings->operators); + $this->sanitizers = new SanitizerBag($settings->sanitizers); + $this->fieldMap = new SanitizerBag($settings->fieldMaps); + } - /** - * Initialize the Resources with the provided settings. - * - * @param FilterableSettings $settings - * @return Resources - */ - public function setOperators(array $operators) - { - $this->operators->fill($operators); - return $this; - } + /** + * Initialize the Resources with the provided settings. + * + * @param FilterableSettings $settings + * + * @return Resources + */ + public function setOperators(array $operators) + { + $this->operators->fill($operators); + + return $this; + } } diff --git a/src/Foundation/Sorting/Sorter.php b/src/Foundation/Sorting/Sorter.php index b4a6fd4..058f919 100644 --- a/src/Foundation/Sorting/Sorter.php +++ b/src/Foundation/Sorting/Sorter.php @@ -2,483 +2,514 @@ namespace Kettasoft\Filterable\Foundation\Sorting; -use Illuminate\Http\Request; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Http\Request; use Kettasoft\Filterable\Engines\Contracts\Appliable; use Kettasoft\Filterable\Foundation\Contracts\Sortable; /** - * Class Sorter + * Class Sorter. * * Provides functionality to manage and apply sorting rules to Eloquent queries. - * @package Kettasoft\Filterable\Foundation\Sorting + * * @mixin \Kettasoft\Filterable\Foundation\Contracts\Sortable + * * @link https://kettasoft.github.io/filterable/sorting */ class Sorter implements Appliable, Sortable { - /** - * List of allowed sortable fields. - * - * @var array - */ - protected array $allowed = []; - - /** - * Default sorting definition. - * - * @var array{0: string, 1: string}|null - */ - protected ?array $default = null; - - /** - * Aliases for sorting presets. - * Example: ['recent' => [['created_at', 'desc']]] - * - * @var array> - */ - protected array $aliases = []; - - /** - * Field mapping for input to database columns. - * - * @var array - */ - protected array $map = []; - - /** - * Configuration settings for the sorter. - * - * @var \Illuminate\Support\Collection - */ - protected \Illuminate\Support\Collection $config; - - /** - * The key used for sorting. - * @var string - */ - protected $sortKey; - - /** - * Delimiter for multi-field sorting. - * - * @var string - */ - protected $delimiter; - - /** - * Create a new Sorter instance. - * - * @param Request $request - */ - public function __construct(protected Request $request, array|null $config = null) - { - $this->config = $config ? collect($config) : collect(config('filterable.sorting', [])); - } - - /** - * Create a new Sorter instance. - * - * @param Request $request - * @param array|null $config - * @return static - */ - public static function make(Request $request, array|null $config = null): self - { - return new self($request, $config); - } - - /** - * Map input fields to database columns. - * - * @param array $fields - * @return $this - */ - public function map(array $fields): self - { - $this->map = $fields; - return $this; - } - - /** - * Get the mapped database column for a given input field. - * - * @param string $field - * @return string - */ - public function getFieldMapping(string $field): string - { - return $this->map[$field] ?? $field; - } - - /** - * Define which fields are allowed for sorting. - * - * @param array $fields - * @return $this - */ - public function allow(array $fields): self - { - $this->allowed = $fields; - return $this; - } - - /** - * Allow sorting on all fields. - * Note: Use with caution, as this may expose sensitive fields. - * - * @return $this - */ - public function allowAll(): self - { - $this->allowed = ['*']; - return $this; - } - - /** - * Define a default sorting field and direction. - * - * @param string $field - * @param string $direction 'asc' or 'desc' - * @return $this - */ - public function default(string $field, string $direction = 'asc'): self - { - $this->default = [$field, $direction]; - return $this; - } - - /** - * Define default sorting using an array. - * - * @param array{0: string, 1: string} $defaults - * @return $this - */ - public function defaults(array $defaults): self - { - if (count($defaults) === 2 && is_string($defaults[0]) && is_string($defaults[1])) { - return $this->default($defaults[0], $defaults[1]); + /** + * List of allowed sortable fields. + * + * @var array + */ + protected array $allowed = []; + + /** + * Default sorting definition. + * + * @var array{0: string, 1: string}|null + */ + protected ?array $default = null; + + /** + * Aliases for sorting presets. + * Example: ['recent' => [['created_at', 'desc']]]. + * + * @var array> + */ + protected array $aliases = []; + + /** + * Field mapping for input to database columns. + * + * @var array + */ + protected array $map = []; + + /** + * Configuration settings for the sorter. + * + * @var \Illuminate\Support\Collection + */ + protected \Illuminate\Support\Collection $config; + + /** + * The key used for sorting. + * + * @var string + */ + protected $sortKey; + + /** + * Delimiter for multi-field sorting. + * + * @var string + */ + protected $delimiter; + + /** + * Create a new Sorter instance. + * + * @param Request $request + */ + public function __construct(protected Request $request, ?array $config = null) + { + $this->config = $config ? collect($config) : collect(config('filterable.sorting', [])); + } + + /** + * Create a new Sorter instance. + * + * @param Request $request + * @param array|null $config + * + * @return static + */ + public static function make(Request $request, ?array $config = null): self + { + return new self($request, $config); + } + + /** + * Map input fields to database columns. + * + * @param array $fields + * + * @return $this + */ + public function map(array $fields): self + { + $this->map = $fields; + + return $this; + } + + /** + * Get the mapped database column for a given input field. + * + * @param string $field + * + * @return string + */ + public function getFieldMapping(string $field): string + { + return $this->map[$field] ?? $field; } - throw new \InvalidArgumentException('Defaults must be an array with exactly two string elements: [field, direction].'); - } - - /** - * Add an alias (preset) for sorting. - * - * Example: - * $sortable->alias("popular", [['views', 'desc'], ['likes', 'desc']]); - * - * @param string $name - * @param array $sorting - * @return $this - */ - public function alias(string $name, array $sorting): self - { - $this->aliases[$name] = $sorting; - return $this; - } - - /** - * Add multiple aliases (presets) for sorting. - * - * Example: - * $sortable->aliases([ - * "popular" => [['views', 'desc'], ['likes', 'desc']], - * "recent" => [['created_at', 'desc']] - * ]); - * - * @param array> $aliases - * @return $this - */ - - public function aliases(array $aliases): self - { - foreach ($aliases as $name => $sorting) { - $this->alias($name, $sorting); + /** + * Define which fields are allowed for sorting. + * + * @param array $fields + * + * @return $this + */ + public function allow(array $fields): self + { + $this->allowed = $fields; + + return $this; } - return $this; - } - - /** - * Set the request key to look for sorting parameters. - * - * @param string $key - * @return $this - * @throws \InvalidArgumentException - */ - public function setSortKey(string $key): self - { - if (empty($key)) { - throw new \InvalidArgumentException('Sort key cannot be empty.'); + + /** + * Allow sorting on all fields. + * Note: Use with caution, as this may expose sensitive fields. + * + * @return $this + */ + public function allowAll(): self + { + $this->allowed = ['*']; + + return $this; } - $this->sortKey = $key; - return $this; - } - - /** - * Set the delimiter for multi-field sorting. - * - * @param string $delimiter - * @throws \InvalidArgumentException - * @return $this - */ - public function setDelimiter(string $delimiter): self - { - if (empty($delimiter)) { - throw new \InvalidArgumentException('Delimiter cannot be empty.'); + /** + * Define a default sorting field and direction. + * + * @param string $field + * @param string $direction 'asc' or 'desc' + * + * @return $this + */ + public function default(string $field, string $direction = 'asc'): self + { + $this->default = [$field, $direction]; + + return $this; } - $this->delimiter = $delimiter; - return $this; - } - - /** - * Set the position of null values in sorting. - * - * @param string|null $position 'first', 'last', or null for default DB behavior - * @return $this - * @throws \InvalidArgumentException - */ - public function setNullsPosition(string|null $position = null): self - { - if (!in_array(strtolower($position), ['first', 'last', null], true)) { - throw new \InvalidArgumentException('Nulls position must be either "first" or "last" or "null".'); + /** + * Define default sorting using an array. + * + * @param array{0: string, 1: string} $defaults + * + * @return $this + */ + public function defaults(array $defaults): self + { + if (count($defaults) === 2 && is_string($defaults[0]) && is_string($defaults[1])) { + return $this->default($defaults[0], $defaults[1]); + } + + throw new \InvalidArgumentException('Defaults must be an array with exactly two string elements: [field, direction].'); } - $this->config->put('nulls_position', strtolower($position)); - return $this; - } + /** + * Add an alias (preset) for sorting. + * + * Example: + * $sortable->alias("popular", [['views', 'desc'], ['likes', 'desc']]); + * + * @param string $name + * @param array $sorting + * + * @return $this + */ + public function alias(string $name, array $sorting): self + { + $this->aliases[$name] = $sorting; + + return $this; + } - /** - * Apply sorting to the query. - * - * @param \Illuminate\Contracts\Database\Eloquent\Builder $query - * @return Builder - */ - public function apply(Builder $query): Builder - { - // Use provided input or fallback to alias/default - $sortInput = $this->request->input($this->getSortKey(), ''); + /** + * Add multiple aliases (presets) for sorting. + * + * Example: + * $sortable->aliases([ + * "popular" => [['views', 'desc'], ['likes', 'desc']], + * "recent" => [['created_at', 'desc']] + * ]); + * + * @param array> $aliases + * + * @return $this + */ + public function aliases(array $aliases): self + { + foreach ($aliases as $name => $sorting) { + $this->alias($name, $sorting); + } + + return $this; + } - $aliases = array_merge($this->config->get('aliases'), $this->aliases); + /** + * Set the request key to look for sorting parameters. + * + * @param string $key + * + * @throws \InvalidArgumentException + * + * @return $this + */ + public function setSortKey(string $key): self + { + if (empty($key)) { + throw new \InvalidArgumentException('Sort key cannot be empty.'); + } + + $this->sortKey = $key; + + return $this; + } - $exploded = $this->parseSortInput($sortInput); + /** + * Set the delimiter for multi-field sorting. + * + * @param string $delimiter + * + * @throws \InvalidArgumentException + * + * @return $this + */ + public function setDelimiter(string $delimiter): self + { + if (empty($delimiter)) { + throw new \InvalidArgumentException('Delimiter cannot be empty.'); + } + + $this->delimiter = $delimiter; + + return $this; + } - $fields = $this->config['multi_sort'] - ? $exploded - : [reset($exploded)]; + /** + * Set the position of null values in sorting. + * + * @param string|null $position 'first', 'last', or null for default DB behavior + * + * @throws \InvalidArgumentException + * + * @return $this + */ + public function setNullsPosition(?string $position = null): self + { + if (!in_array(strtolower($position), ['first', 'last', null], true)) { + throw new \InvalidArgumentException('Nulls position must be either "first" or "last" or "null".'); + } + + $this->config->put('nulls_position', strtolower($position)); + + return $this; + } - if (!empty($aliases)) { - // Handle aliases - $this->applyAliases($fields, $query); + /** + * Apply sorting to the query. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $query + * + * @return Builder + */ + public function apply(Builder $query): Builder + { + // Use provided input or fallback to alias/default + $sortInput = $this->request->input($this->getSortKey(), ''); + + $aliases = array_merge($this->config->get('aliases'), $this->aliases); + + $exploded = $this->parseSortInput($sortInput); + + $fields = $this->config['multi_sort'] + ? $exploded + : [reset($exploded)]; + + if (!empty($aliases)) { + // Handle aliases + $this->applyAliases($fields, $query); + } + + foreach ($fields as $field) { + [$column, $direction] = $this->parseField($field); + + if ($this->isAllowed($column) && !isset($aliases[$column])) { + $this->orderBy($this->getFieldMapping($column), $direction, $query); + } + } + + // Apply default sorting if no sort param is provided + if ($this->default) { + [$field, $dir] = $this->default; + if (in_array($field, $this->allowed, true)) { + $this->orderBy($field, $dir, $query); + } + } + + return $query; } - foreach ($fields as $field) { - [$column, $direction] = $this->parseField($field); + /** + * Apply sorting aliases to the query. + * + * @param string $sortInput + * @param \Illuminate\Contracts\Database\Eloquent\Builder $query + * + * @return void + */ + protected function applyAliases(array $fields, Builder $query): void + { + $aliases = array_merge($this->config->get('aliases'), $this->aliases); + + foreach ($fields as $field) { + if (isset($aliases[$field])) { + $this->applyDefault($aliases[$field], $query); + } + } + } - if ($this->isAllowed($column) && ! isset($aliases[$column])) { - $this->orderBy($this->getFieldMapping($column), $direction, $query); - } + /** + * Apply default sorting pattern to the query. + * + * @param array $pattern + * @param \Illuminate\Contracts\Database\Eloquent\Builder $query + * + * @return void + */ + protected function applyDefault(array $pattern, Builder $query): void + { + foreach ($pattern as [$aliasField, $direction]) { + if ($this->isAllowed($aliasField)) { + $this->orderBy($aliasField, $direction, $query); + } + } } - // Apply default sorting if no sort param is provided - if ($this->default) { - [$field, $dir] = $this->default; - if (in_array($field, $this->allowed, true)) { - $this->orderBy($field, $dir, $query); - } + /** + * Reset the sorter state. + * + * @return void + */ + public function reset(): void + { + $this->allowed = []; + $this->default = null; + $this->aliases = []; + $this->map = []; } - return $query; - } - - /** - * Apply sorting aliases to the query. - * - * @param string $sortInput - * @param \Illuminate\Contracts\Database\Eloquent\Builder $query - * @return void - */ - protected function applyAliases(array $fields, Builder $query): void - { - $aliases = array_merge($this->config->get('aliases'), $this->aliases); - - foreach ($fields as $field) { - if (isset($aliases[$field])) { - $this->applyDefault($aliases[$field], $query); - } + /** + * Check if the field is allowed for sorting. + * + * @param string $field + * + * @return bool + */ + protected function isAllowed(string $field): bool + { + return in_array($field, array_merge($this->config['allowed'], $this->allowed), true) || $this->allowed == ['*']; } - } - - /** - * Apply default sorting pattern to the query. - * - * @param array $pattern - * @param \Illuminate\Contracts\Database\Eloquent\Builder $query - * @return void - */ - protected function applyDefault(array $pattern, Builder $query): void - { - foreach ($pattern as [$aliasField, $direction]) { - if ($this->isAllowed($aliasField)) { - $this->orderBy($aliasField, $direction, $query); - } + + /** + * Parse the sort input into individual fields. + * + * @param string $input + * + * @return array + */ + protected function parseSortInput(string $input): array + { + $fields = $this->config['multi_sort'] + ? explode($this->getDelimiter(), $input) + : [$input]; + + return array_map('trim', $fields); } - } - - /** - * Reset the sorter state. - * - * @return void - */ - public function reset(): void - { - $this->allowed = []; - $this->default = null; - $this->aliases = []; - $this->map = []; - } - - /** - * Check if the field is allowed for sorting. - * - * @param string $field - * @return bool - */ - protected function isAllowed(string $field): bool - { - return in_array($field, array_merge($this->config['allowed'], $this->allowed), true) || $this->allowed == ['*']; - } - - /** - * Parse the sort input into individual fields. - * - * @param string $input - * @return array - */ - protected function parseSortInput(string $input): array - { - $fields = $this->config['multi_sort'] - ? explode($this->getDelimiter(), $input) - : [$input]; - - return array_map('trim', $fields); - } - - /** - * Parse sorting field & direction. - * - * @param string $field - * @return array{0: string, 1: string} - */ - protected function parseField(string $field): array - { - $prefix = $this->config['direction_map']['prefix'] ?? '-'; - - if (str_starts_with($field, $prefix)) { - return [ltrim($field, $prefix), 'desc']; + + /** + * Parse sorting field & direction. + * + * @param string $field + * + * @return array{0: string, 1: string} + */ + protected function parseField(string $field): array + { + $prefix = $this->config['direction_map']['prefix'] ?? '-'; + + if (str_starts_with($field, $prefix)) { + return [ltrim($field, $prefix), 'desc']; + } + + return [$field, 'asc']; + } + + /** + * Apply sorting with nulls position handling. + * + * @param string $field + * @param string $direction + * + * @return void + */ + protected function orderBy(string $field, string $direction, Builder $query): void + { + $nulls = $this->getNullsPosition(); + + if ($nulls && in_array(strtolower($nulls), ['first', 'last'])) { + $query->orderByRaw("{$field} {$direction} NULLS ".strtoupper($nulls)); + } else { + $query->orderBy($field, $direction); + } + } + + /** + * Get the sorting configuration. + * + * @return array + */ + public function getConfig(): array + { + return $this->config->toArray(); + } + + /** + * Get the allowed sorting fields. + * + * @return array + */ + public function getAllowed(): array + { + return $this->allowed; + } + + /** + * Get the default sorting field and direction. + * + * @return array|null + */ + public function getDefault(): ?array + { + return $this->default; + } + + /** + * Get the sorting aliases. + * + * @return array + */ + public function getAliases(): array + { + return $this->aliases; + } + + /** + * Get the field mapping for input to database columns. + * + * @return array + */ + public function getMap(): array + { + return $this->map; + } + + /** + * Get the sort key used in the request. + * + * @return string + */ + public function getSortKey(): string + { + return $this->sortKey ?? $this->config->get('sort_key', 'sort'); + } + + /** + * Get the delimiter used for multi-field sorting. + * + * @return string + */ + public function getDelimiter(): string + { + return $this->delimiter ?? $this->config->get('delimiter', ','); } - return [$field, 'asc']; - } - - /** - * Apply sorting with nulls position handling. - * - * @param string $field - * @param string $direction - * @return void - */ - protected function orderBy(string $field, string $direction, Builder $query): void - { - $nulls = $this->getNullsPosition(); - - if ($nulls && in_array(strtolower($nulls), ['first', 'last'])) { - $query->orderByRaw("{$field} {$direction} NULLS " . strtoupper($nulls)); - } else { - $query->orderBy($field, $direction); + /** + * Get the position of null option in sorting. + * + * @return string|null + */ + public function getNullsPosition(): ?string + { + return $this->config->get('nulls_position', null); } - } - - /** - * Get the sorting configuration. - * - * @return array - */ - public function getConfig(): array - { - return $this->config->toArray(); - } - - /** - * Get the allowed sorting fields. - * - * @return array - */ - public function getAllowed(): array - { - return $this->allowed; - } - - /** - * Get the default sorting field and direction. - * - * @return array|null - */ - public function getDefault(): ?array - { - return $this->default; - } - - /** - * Get the sorting aliases. - * - * @return array - */ - public function getAliases(): array - { - return $this->aliases; - } - - /** - * Get the field mapping for input to database columns. - * - * @return array - */ - public function getMap(): array - { - return $this->map; - } - - /** - * Get the sort key used in the request. - * - * @return string - */ - public function getSortKey(): string - { - return $this->sortKey ?? $this->config->get('sort_key', 'sort'); - } - - /** - * Get the delimiter used for multi-field sorting. - * - * @return string - */ - public function getDelimiter(): string - { - return $this->delimiter ?? $this->config->get('delimiter', ','); - } - - /** - * Get the position of null option in sorting. - * - * @return string|null - */ - public function getNullsPosition(): string|null - { - return $this->config->get('nulls_position', null); - } } diff --git a/src/Foundation/Traits/HandleFluentReturn.php b/src/Foundation/Traits/HandleFluentReturn.php index 08f2119..7fdbb1a 100644 --- a/src/Foundation/Traits/HandleFluentReturn.php +++ b/src/Foundation/Traits/HandleFluentReturn.php @@ -6,25 +6,26 @@ trait HandleFluentReturn { - /** - * Processes the result of a forwarded call to the builder. - * - * If the result is an instance of Builder, it updates the internal builder - * reference and returns $this for fluent chaining. Otherwise, it returns the result as-is. - * - * @param mixed $result The result returned from the forwarded call. - * @return mixed Returns $this if the result is a Builder, otherwise returns the original result. - */ - protected function handleFluentReturn($method, $args) - { + /** + * Processes the result of a forwarded call to the builder. + * + * If the result is an instance of Builder, it updates the internal builder + * reference and returns $this for fluent chaining. Otherwise, it returns the result as-is. + * + * @param mixed $result The result returned from the forwarded call. + * + * @return mixed Returns $this if the result is a Builder, otherwise returns the original result. + */ + protected function handleFluentReturn($method, $args) + { + $result = $this->forwardCallTo($this->builder, $method, $args); - $result = $this->forwardCallTo($this->builder, $method, $args); + if ($result instanceof QueryBuilderInterface) { + $this->builder = $result; - if ($result instanceof QueryBuilderInterface) { - $this->builder = $result; - return $this; - } + return $this; + } - return $result; - } + return $result; + } } diff --git a/src/HttpIntegration/HeaderDrivenEngineSelector.php b/src/HttpIntegration/HeaderDrivenEngineSelector.php index 41f3b36..ed00fec 100644 --- a/src/HttpIntegration/HeaderDrivenEngineSelector.php +++ b/src/HttpIntegration/HeaderDrivenEngineSelector.php @@ -8,96 +8,105 @@ final class HeaderDrivenEngineSelector { - /** - * Request instance. - * @var Request - */ - protected Request $request; - - /** - * HTTP Header-driven mode config. - * @var Collection - */ - protected Collection $config; - - /** - * HeaderDrivenEngineSelector constructor. - * @param \Illuminate\Http\Request $request - */ - public function __construct(Request $request, $config = []) - { - $this->request = $request; - $this->config = collect(empty($config) ? config('filterable.header_driven_mode', []) : $config); - } - - /** - * Resolve the HTTP header to engine name. - * @return string - */ - public function resolve(): string - { - $defaultEngine = $this->config->get('default_engine', config('filterable.default_engine')); - - if (! $this->config->get('enabled', false)) { - return $defaultEngine; + /** + * Request instance. + * + * @var Request + */ + protected Request $request; + + /** + * HTTP Header-driven mode config. + * + * @var Collection + */ + protected Collection $config; + + /** + * HeaderDrivenEngineSelector constructor. + * + * @param \Illuminate\Http\Request $request + */ + public function __construct(Request $request, $config = []) + { + $this->request = $request; + $this->config = collect(empty($config) ? config('filterable.header_driven_mode', []) : $config); } - $headerValue = $this->getHeaderValue(); + /** + * Resolve the HTTP header to engine name. + * + * @return string + */ + public function resolve(): string + { + $defaultEngine = $this->config->get('default_engine', config('filterable.default_engine')); - if (! $headerValue) { - return $defaultEngine; + if (!$this->config->get('enabled', false)) { + return $defaultEngine; + } + + $headerValue = $this->getHeaderValue(); + + if (!$headerValue) { + return $defaultEngine; + } + + $mappedEngine = $this->mapToEngine($headerValue); + + return array_key_exists($mappedEngine, config('filterable.engines')) ? $mappedEngine : $defaultEngine; } - $mappedEngine = $this->mapToEngine($headerValue); - - return array_key_exists($mappedEngine, config('filterable.engines')) ? $mappedEngine : $defaultEngine; - } - - /** - * Get filter mode name from header. - * @return array|string|null - */ - protected function getHeaderValue() - { - return $this->request->header( - $this->config->get('header_name') - ); - } - - /** - * Mapping engine name. - * @param string $headerValue - */ - protected function mapToEngine(string $headerValue) - { - // Check engine map first. - $engine = $this->config->get('engine_map')[$headerValue] ?? $headerValue; - - $this->validateEngine($engine); - - return $engine; - } - - /** - * Validate engine. - * @param string $engine - * @return void - * @throws ValidationException - */ - protected function validateEngine(string $engine) - { - $allowedEngines = $this->config->get('allowed_engines') ?: array_keys(config('filterable.engines')); - - if (in_array($engine, $allowedEngines)) { - return; + /** + * Get filter mode name from header. + * + * @return array|string|null + */ + protected function getHeaderValue() + { + return $this->request->header( + $this->config->get('header_name') + ); + } + + /** + * Mapping engine name. + * + * @param string $headerValue + */ + protected function mapToEngine(string $headerValue) + { + // Check engine map first. + $engine = $this->config->get('engine_map')[$headerValue] ?? $headerValue; + + $this->validateEngine($engine); + + return $engine; } - if ($this->config->get('fallback_strategy', 'error') === 'error') { - throw ValidationException::withMessages([ - $this->config->get('header_name') => [ - 'Invalid filter engine specified. ' . 'Allowed: ' . implode(', ', $this->config->get('allowed_engines', [])) - ] - ]); + /** + * Validate engine. + * + * @param string $engine + * + * @throws ValidationException + * + * @return void + */ + protected function validateEngine(string $engine) + { + $allowedEngines = $this->config->get('allowed_engines') ?: array_keys(config('filterable.engines')); + + if (in_array($engine, $allowedEngines)) { + return; + } + + if ($this->config->get('fallback_strategy', 'error') === 'error') { + throw ValidationException::withMessages([ + $this->config->get('header_name') => [ + 'Invalid filter engine specified. '.'Allowed: '.implode(', ', $this->config->get('allowed_engines', [])), + ], + ]); + } } - } } diff --git a/src/Pipes/FilterAuthorizationPipe.php b/src/Pipes/FilterAuthorizationPipe.php index 57185dd..c011ead 100644 --- a/src/Pipes/FilterAuthorizationPipe.php +++ b/src/Pipes/FilterAuthorizationPipe.php @@ -3,22 +3,23 @@ namespace Kettasoft\Filterable\Pipes; use Illuminate\Validation\UnauthorizedException; -use Symfony\Component\HttpFoundation\Response; use Kettasoft\Filterable\Contracts\Authorizable; +use Symfony\Component\HttpFoundation\Response; class FilterAuthorizationPipe { - /** - * Handle incomming pipe. - * @param \Kettasoft\Filterable\Contracts\Authorizable $filter - * @param mixed $next - */ - public function handle(Authorizable $filter, $next) - { - if (!$filter->authorize()) { - throw new UnauthorizedException("You are not authorized to make this filter", Response::HTTP_UNAUTHORIZED); - } + /** + * Handle incomming pipe. + * + * @param \Kettasoft\Filterable\Contracts\Authorizable $filter + * @param mixed $next + */ + public function handle(Authorizable $filter, $next) + { + if (!$filter->authorize()) { + throw new UnauthorizedException('You are not authorized to make this filter', Response::HTTP_UNAUTHORIZED); + } - return $next($filter); - } + return $next($filter); + } } diff --git a/src/Pipes/ValidateBeforeFilteringPipe.php b/src/Pipes/ValidateBeforeFilteringPipe.php index 2ecaaef..9c5b534 100644 --- a/src/Pipes/ValidateBeforeFilteringPipe.php +++ b/src/Pipes/ValidateBeforeFilteringPipe.php @@ -6,15 +6,16 @@ class ValidateBeforeFilteringPipe { - /** - * Handle incomming pipe. - * @param \Kettasoft\Filterable\Contracts\Validatable $context - * @param mixed $next - */ - public function handle(Validatable $context, $next) - { - $context->validate(); + /** + * Handle incomming pipe. + * + * @param \Kettasoft\Filterable\Contracts\Validatable $context + * @param mixed $next + */ + public function handle(Validatable $context, $next) + { + $context->validate(); - return $next($context); - } + return $next($context); + } } diff --git a/src/Providers/AutoRegisterFilterableServiceProvider.php b/src/Providers/AutoRegisterFilterableServiceProvider.php index 7abaeb1..6cfb9cb 100644 --- a/src/Providers/AutoRegisterFilterableServiceProvider.php +++ b/src/Providers/AutoRegisterFilterableServiceProvider.php @@ -2,35 +2,35 @@ namespace Kettasoft\Filterable\Providers; -use Illuminate\Support\ServiceProvider; use Illuminate\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\FilterResolver; +use Illuminate\Support\ServiceProvider; use Kettasoft\Filterable\Contracts\FilterableContext; +use Kettasoft\Filterable\Support\FilterResolver; class AutoRegisterFilterableServiceProvider extends ServiceProvider { - /** - * Bootstrap any application services. - * - * @return void - */ - public function boot() - { - Builder::macro('filter', function (FilterableContext|string|null $filter = null) { - /** @var Builder */ - $builder = $this; + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + Builder::macro('filter', function (FilterableContext|string|null $filter = null) { + /** @var Builder */ + $builder = $this; - return (new FilterResolver($builder, $filter))->resolve(); - }); - } + return (new FilterResolver($builder, $filter))->resolve(); + }); + } - /** - * Register any application services. - * - * @return void - */ - public function register() - { + /** + * Register any application services. + * + * @return void + */ + public function register() + { // - } + } } diff --git a/src/Providers/FilterableServiceProvider.php b/src/Providers/FilterableServiceProvider.php index ebe965a..1fe0167 100644 --- a/src/Providers/FilterableServiceProvider.php +++ b/src/Providers/FilterableServiceProvider.php @@ -2,35 +2,34 @@ namespace Kettasoft\Filterable\Providers; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\Request; -use InvalidArgumentException; -use Kettasoft\Filterable\Filterable; use Illuminate\Support\ServiceProvider; -use Illuminate\Contracts\Foundation\Application; -use Kettasoft\Filterable\Commands\MakeFilterCommand; -use Kettasoft\Filterable\Commands\TestFilterCommand; -use Kettasoft\Filterable\Commands\ListFiltersCommand; +use InvalidArgumentException; +use Kettasoft\Filterable\Commands\FilterableDiscoverCommand; use Kettasoft\Filterable\Commands\InspectFilterCommand; +use Kettasoft\Filterable\Commands\ListFiltersCommand; +use Kettasoft\Filterable\Commands\MakeFilterCommand; use Kettasoft\Filterable\Commands\SetupFilterableCommand; -use Kettasoft\Filterable\Commands\FilterableDiscoverCommand; -use Kettasoft\Filterable\Foundation\Events\FilterableEventManager; -use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; +use Kettasoft\Filterable\Commands\TestFilterCommand; +use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Foundation\Caching\CacheInvalidationObserver; -use Kettasoft\Filterable\Foundation\Profiler\Storage\FileProfilerStorage; -use Kettasoft\Filterable\Foundation\Profiler\Storage\DatabaseProfilerStorage; +use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; +use Kettasoft\Filterable\Foundation\Events\FilterableEventManager; use Kettasoft\Filterable\Foundation\Profiler\Contracts\ProfilerStorageContract; +use Kettasoft\Filterable\Foundation\Profiler\Storage\DatabaseProfilerStorage; +use Kettasoft\Filterable\Foundation\Profiler\Storage\FileProfilerStorage; /** * Service provider for the Kettasoft Filterable package. - * + * * This provider handles: * - Configuration publishing and merging * - Service bindings and singletons * - Command registration * - Stub publishing * - Profiler storage configuration - * - * @package Kettasoft\Filterable\Providers + * * @method void registerCustomEngines() * @method void registerCustomSanitizers() * @method void registerAdditionalServices() @@ -43,12 +42,12 @@ class FilterableServiceProvider extends ServiceProvider /** * Configuration file path relative to package root. */ - private const CONFIG_PATH = __DIR__ . '/../../config/filterable.php'; + private const CONFIG_PATH = __DIR__.'/../../config/filterable.php'; /** * Stubs directory path relative to package root. */ - private const STUBS_PATH = __DIR__ . '/../../stubs/'; + private const STUBS_PATH = __DIR__.'/../../stubs/'; /** * Package configuration key. @@ -65,12 +64,12 @@ class FilterableServiceProvider extends ServiceProvider */ private const PROFILER_DRIVERS = [ 'database' => DatabaseProfilerStorage::class, - 'log' => FileProfilerStorage::class, + 'log' => FileProfilerStorage::class, ]; /** * Bootstrap any application services. - * + * * This method is called after all providers have been registered. * It handles asset publishing, command registration, and other * bootstrap operations that depend on the container. @@ -87,7 +86,7 @@ public function boot(): void /** * Register any application services. - * + * * This method is called during the registration phase and should * only register bindings in the container. It should not perform * any operations that depend on other services being available. @@ -106,7 +105,7 @@ public function register(): void /** * Publish package assets (config files, stubs, etc.). - * + * * Separates different types of publishable assets with clear * tags for selective publishing. * @@ -116,24 +115,24 @@ protected function publishAssets(): void { // Publish configuration file $this->publishes([ - self::CONFIG_PATH => config_path(self::CONFIG_KEY . '.php'), - ], [self::CONFIG_KEY . '-config', 'config']); + self::CONFIG_PATH => config_path(self::CONFIG_KEY.'.php'), + ], [self::CONFIG_KEY.'-config', 'config']); // Publish stub files for code generation $this->publishes([ self::STUBS_PATH => base_path('stubs'), - ], [self::CONFIG_KEY . '-stubs', 'stubs']); + ], [self::CONFIG_KEY.'-stubs', 'stubs']); // Allow publishing all assets at once $this->publishes([ - self::CONFIG_PATH => config_path(self::CONFIG_KEY . '.php'), - self::STUBS_PATH => base_path('stubs'), + self::CONFIG_PATH => config_path(self::CONFIG_KEY.'.php'), + self::STUBS_PATH => base_path('stubs'), ], self::CONFIG_KEY); } /** * Merge package configuration with application configuration. - * + * * This ensures package defaults are available even if the user * hasn't published the config file. * @@ -146,7 +145,7 @@ protected function mergeConfiguration(): void /** * Register core service bindings. - * + * * Registers the main Filterable class as a singleton to ensure * consistent state across the application request lifecycle. * @@ -172,7 +171,7 @@ class_alias(\Kettasoft\Filterable\Facades\Filterable::class, 'Filterable'); /** * Register the FilterableEventManager as a singleton. - * + * * This ensures that only one instance of the event manager exists * throughout the application lifecycle, maintaining consistent * event listener registration across the entire application. @@ -191,7 +190,7 @@ protected function registerEventManager(): void /** * Register the FilterableCacheManager as a singleton. - * + * * This ensures that only one instance of the cache manager exists * throughout the application lifecycle, providing consistent caching * behavior across all filterable instances. @@ -210,12 +209,13 @@ protected function registerCacheManager(): void /** * Register profiler storage implementations. - * + * * Uses a factory pattern to create storage instances based on * configuration, with clear error handling for invalid drivers. * - * @return void * @throws InvalidArgumentException When an unsupported storage driver is configured + * + * @return void */ protected function registerProfilerStorage(): void { @@ -224,8 +224,8 @@ protected function registerProfilerStorage(): void if (!isset(self::PROFILER_DRIVERS[$driver])) { throw new InvalidArgumentException( - "Unsupported profiler storage driver [{$driver}]. " . - "Supported drivers are: " . implode(', ', array_keys(self::PROFILER_DRIVERS)) + "Unsupported profiler storage driver [{$driver}]. ". + 'Supported drivers are: '.implode(', ', array_keys(self::PROFILER_DRIVERS)) ); } @@ -237,7 +237,7 @@ protected function registerProfilerStorage(): void /** * Register cache invalidation observers for auto-invalidation. - * + * * Sets up automatic cache invalidation when configured models change. * * @return void @@ -249,7 +249,7 @@ protected function registerCacheInvalidationObservers(): void /** * Register Artisan commands. - * + * * Only registers commands when running in console to avoid * unnecessary overhead in web requests. * @@ -273,7 +273,7 @@ protected function registerCommands(): void /** * Register package extensions and hooks. - * + * * Provides extensibility points for developers to customize * package behavior without modifying core files. * @@ -299,7 +299,7 @@ protected function registerExtensions(): void /** * Boot package extensions and perform post-registration setup. - * + * * Provides boot-time extensibility points for operations that * require the full container to be available. * @@ -325,7 +325,7 @@ protected function bootExtensions(): void /** * Get the services provided by the provider. - * + * * This method helps Laravel optimize the container by knowing * which services this provider offers. * @@ -344,7 +344,7 @@ public function provides(): array /** * Determine if the provider is deferred. - * + * * Returns false to ensure the provider is always loaded since * it provides essential configuration merging and publishing. * diff --git a/src/Sanitization/Contracts/Sanitizable.php b/src/Sanitization/Contracts/Sanitizable.php index b3fd217..0e20418 100644 --- a/src/Sanitization/Contracts/Sanitizable.php +++ b/src/Sanitization/Contracts/Sanitizable.php @@ -4,10 +4,12 @@ interface Sanitizable { - /** - * Sanitize incoming value. - * @param mixed $value - * @return mixed - */ - public function sanitize($value): mixed; + /** + * Sanitize incoming value. + * + * @param mixed $value + * + * @return mixed + */ + public function sanitize($value): mixed; } diff --git a/src/Sanitization/Contracts/SanitizeHandler.php b/src/Sanitization/Contracts/SanitizeHandler.php index 81983ae..dd5eb02 100644 --- a/src/Sanitization/Contracts/SanitizeHandler.php +++ b/src/Sanitization/Contracts/SanitizeHandler.php @@ -4,16 +4,19 @@ interface SanitizeHandler { - /** - * SanitizeHandler constructor. - * @param \Kettasoft\Filterable\Sanitization\Contracts\Sanitizable|\Closure|string|array $sanitizer - */ - public function __construct(Sanitizable|\Closure|string|array $sanitizer); + /** + * SanitizeHandler constructor. + * + * @param \Kettasoft\Filterable\Sanitization\Contracts\Sanitizable|\Closure|string|array $sanitizer + */ + public function __construct(Sanitizable|\Closure|string|array $sanitizer); - /** - * Handle incomming sanitizer. - * @param mixed $value - * @return mixed - */ - public function handle(mixed $value): mixed; + /** + * Handle incomming sanitizer. + * + * @param mixed $value + * + * @return mixed + */ + public function handle(mixed $value): mixed; } diff --git a/src/Sanitization/HandlerFactory.php b/src/Sanitization/HandlerFactory.php index 644eb89..a46bf23 100644 --- a/src/Sanitization/HandlerFactory.php +++ b/src/Sanitization/HandlerFactory.php @@ -2,40 +2,44 @@ namespace Kettasoft\Filterable\Sanitization; +use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; use Kettasoft\Filterable\Sanitization\Handlers\ArrayHandler; +use Kettasoft\Filterable\Sanitization\Handlers\ClosureHandler; 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 HandlerFactory { - /** - * Handle sanitize value by sanitizer handlers. - * @param mixed $value - * @param mixed $sanitizer - */ - public static function handle($value, $sanitizer) - { - return static::makeHandler($sanitizer)->handle($value); - } + /** + * Handle sanitize value by sanitizer handlers. + * + * @param mixed $value + * @param mixed $sanitizer + */ + public static function handle($value, $sanitizer) + { + return static::makeHandler($sanitizer)->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"), - }; + /** + * 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; - } + return $handler; + } } diff --git a/src/Sanitization/Handlers/ArrayHandler.php b/src/Sanitization/Handlers/ArrayHandler.php index 68c2f49..26eabe7 100644 --- a/src/Sanitization/Handlers/ArrayHandler.php +++ b/src/Sanitization/Handlers/ArrayHandler.php @@ -7,28 +7,31 @@ class ArrayHandler implements SanitizeHandler { - protected array $sanitizers; + protected array $sanitizers; - /** - * ArrayHandler constructor - * @param mixed $sanitizers - */ - public function __construct($sanitizers) - { - $this->sanitizers = $sanitizers; - } - - /** - * Handle incomming sanitizer. - * @param mixed $value - * @return mixed - */ - public function handle(mixed $value): mixed - { - foreach ($this->sanitizers as $sanitizer) { - $value = HandlerFactory::handle($value, $sanitizer); + /** + * ArrayHandler constructor. + * + * @param mixed $sanitizers + */ + public function __construct($sanitizers) + { + $this->sanitizers = $sanitizers; } - return $value; - } + /** + * Handle incomming sanitizer. + * + * @param mixed $value + * + * @return mixed + */ + public function handle(mixed $value): mixed + { + foreach ($this->sanitizers as $sanitizer) { + $value = HandlerFactory::handle($value, $sanitizer); + } + + return $value; + } } diff --git a/src/Sanitization/Handlers/ClosureHandler.php b/src/Sanitization/Handlers/ClosureHandler.php index 5d8ede5..0420de3 100644 --- a/src/Sanitization/Handlers/ClosureHandler.php +++ b/src/Sanitization/Handlers/ClosureHandler.php @@ -6,24 +6,27 @@ class ClosureHandler implements SanitizeHandler { - protected \Closure $sanitizer; + protected \Closure $sanitizer; - /** - * ClosureHandler constructor. - * @param mixed $sanitizer - */ - public function __construct($sanitizer) - { - $this->sanitizer = $sanitizer; - } + /** + * ClosureHandler constructor. + * + * @param mixed $sanitizer + */ + public function __construct($sanitizer) + { + $this->sanitizer = $sanitizer; + } - /** - * Handle incomming sanitizer. - * @param mixed $value - * @return mixed - */ - public function handle(mixed $value): mixed - { - return $this->sanitizer->__invoke($value); - } + /** + * Handle incomming sanitizer. + * + * @param mixed $value + * + * @return mixed + */ + public function handle(mixed $value): mixed + { + return $this->sanitizer->__invoke($value); + } } diff --git a/src/Sanitization/Handlers/ObjectHandler.php b/src/Sanitization/Handlers/ObjectHandler.php index e15303e..c43df7f 100644 --- a/src/Sanitization/Handlers/ObjectHandler.php +++ b/src/Sanitization/Handlers/ObjectHandler.php @@ -7,29 +7,33 @@ class ObjectHandler implements SanitizeHandler { - protected Sanitizable $sanitizer; + protected Sanitizable $sanitizer; - /** - * ObjectHandler constructor. - * @param mixed $sanitizer - * @throws \InvalidArgumentException - */ - public function __construct($sanitizer) - { - if (! ($sanitizer instanceof Sanitizable)) { - throw new \InvalidArgumentException(sprintf("Sanitizer class %s is not implemented from %s interface", get_class($sanitizer), Sanitizable::class)); - } + /** + * ObjectHandler constructor. + * + * @param mixed $sanitizer + * + * @throws \InvalidArgumentException + */ + public function __construct($sanitizer) + { + if (!($sanitizer instanceof Sanitizable)) { + throw new \InvalidArgumentException(sprintf('Sanitizer class %s is not implemented from %s interface', get_class($sanitizer), Sanitizable::class)); + } - $this->sanitizer = $sanitizer; - } + $this->sanitizer = $sanitizer; + } - /** - * Handle incomming sanitizer. - * @param mixed $value - * @return mixed - */ - public function handle(mixed $value): mixed - { - return $this->sanitizer->sanitize($value); - } + /** + * Handle incomming sanitizer. + * + * @param mixed $value + * + * @return mixed + */ + public function handle(mixed $value): mixed + { + return $this->sanitizer->sanitize($value); + } } diff --git a/src/Sanitization/Handlers/StringHandler.php b/src/Sanitization/Handlers/StringHandler.php index 59fe30c..9bf2607 100644 --- a/src/Sanitization/Handlers/StringHandler.php +++ b/src/Sanitization/Handlers/StringHandler.php @@ -7,24 +7,26 @@ class StringHandler implements SanitizeHandler { - protected Sanitizable $sanitizer; + protected Sanitizable $sanitizer; - public function __construct($sanitizer) - { - if (! is_a($sanitizer, Sanitizable::class, true)) { - throw new \InvalidArgumentException(sprintf("Sanitizer class %s is invalid", $sanitizer)); - } + public function __construct($sanitizer) + { + if (!is_a($sanitizer, Sanitizable::class, true)) { + throw new \InvalidArgumentException(sprintf('Sanitizer class %s is invalid', $sanitizer)); + } - $this->sanitizer = new $sanitizer; - } + $this->sanitizer = new $sanitizer(); + } - /** - * Handle incomming sanitizer. - * @param mixed $value - * @return mixed - */ - public function handle(mixed $value): mixed - { - return $this->sanitizer->sanitize($value); - } + /** + * Handle incomming sanitizer. + * + * @param mixed $value + * + * @return mixed + */ + public function handle(mixed $value): mixed + { + return $this->sanitizer->sanitize($value); + } } diff --git a/src/Sanitization/Sanitizer.php b/src/Sanitization/Sanitizer.php index 2626140..b19c4c5 100644 --- a/src/Sanitization/Sanitizer.php +++ b/src/Sanitization/Sanitizer.php @@ -3,74 +3,81 @@ namespace Kettasoft\Filterable\Sanitization; use Illuminate\Support\Traits\ForwardsCalls; -use Kettasoft\Filterable\Sanitization\HandlerFactory; class Sanitizer implements \Countable { - use ForwardsCalls; + use ForwardsCalls; - /** - * Registered sanitizers to operate upon. - * @var array - */ - protected array $sanitizers = []; + /** + * Registered sanitizers to operate upon. + * + * @var array + */ + protected array $sanitizers = []; - /** - * Create new Sanitizer instance. - * @param array $sanitizers - */ - public function __construct(array $sanitizers) - { - $this->sanitizers = $sanitizers; - } - - /** - * Handle sanitizers. - * @param string $field - * @param mixed $value - */ - public function handle(string $field, mixed $value) - { - if (empty($field) || !array_key_exists($field, $this->sanitizers)) { - return $value; + /** + * Create new Sanitizer instance. + * + * @param array $sanitizers + */ + public function __construct(array $sanitizers) + { + $this->sanitizers = $sanitizers; } - foreach ($this->sanitizers as $key => $resolver) { - if ($key === $field) { - $value = HandlerFactory::handle($value, $resolver); - } + /** + * Handle sanitizers. + * + * @param string $field + * @param mixed $value + */ + public function handle(string $field, mixed $value) + { + if (empty($field) || !array_key_exists($field, $this->sanitizers)) { + return $value; + } + + foreach ($this->sanitizers as $key => $resolver) { + if ($key === $field) { + $value = HandlerFactory::handle($value, $resolver); + } + } + + return $value; } - return $value; - } + /** + * Get the number of registered sanitizers. + * + * @return int + */ + public function count(): int + { + return count($this->sanitizers); + } - /** - * Get the number of registered sanitizers. - * @return int - */ - public function count(): int - { - return count($this->sanitizers); - } + /** + * Get registered sanitizers. + * + * @return array + */ + public function getSanitizers(): array + { + return $this->sanitizers; + } - /** - * Get registered sanitizers. - * @return array - */ - public function getSanitizers(): array - { - return $this->sanitizers; - } + /** + * Set sanitizer classes. + * + * @param array $sanitizers + * @param bool $override Override current sanitizers when (true) + * + * @return static + */ + public function setSanitizers(array $sanitizers, bool $override = true): static + { + $this->sanitizers = $override ? $sanitizers : array_merge($this->sanitizers, $sanitizers); - /** - * Set sanitizer classes - * @param array $sanitizers - * @param bool $override Override current sanitizers when (true) - * @return static - */ - public function setSanitizers(array $sanitizers, bool $override = true): static - { - $this->sanitizers = $override ? $sanitizers : array_merge($this->sanitizers, $sanitizers); - return $this; - } + return $this; + } } diff --git a/src/Support/AllowedFieldChecker.php b/src/Support/AllowedFieldChecker.php index d258547..45abe09 100644 --- a/src/Support/AllowedFieldChecker.php +++ b/src/Support/AllowedFieldChecker.php @@ -7,29 +7,32 @@ class AllowedFieldChecker { - /** - * Check if a field is allowed for filtering. - * @param \Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker $context - * @param mixed $field - * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException - * @return bool - */ - public static function check(HasAllowedFieldChecker $context, $field): bool - { - $allowedFields = $context->getAllowedFields(); + /** + * Check if a field is allowed for filtering. + * + * @param \Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker $context + * @param mixed $field + * + * @throws \Kettasoft\Filterable\Exceptions\NotAllowedFieldException + * + * @return bool + */ + public static function check(HasAllowedFieldChecker $context, $field): bool + { + $allowedFields = $context->getAllowedFields(); - if (isset($allowedFields[0]) && $allowedFields[0] === '*') { - return true; - } + if (isset($allowedFields[0]) && $allowedFields[0] === '*') { + return true; + } - if (in_array($field, $allowedFields)) { - return true; - } + if (in_array($field, $allowedFields)) { + return true; + } - if ($context->isStrict()) { - throw new NotAllowedFieldException($field); - } + if ($context->isStrict()) { + throw new NotAllowedFieldException($field); + } - return false; - } + return false; + } } diff --git a/src/Support/ConditionNormalizer.php b/src/Support/ConditionNormalizer.php index 18477ed..aaa5447 100644 --- a/src/Support/ConditionNormalizer.php +++ b/src/Support/ConditionNormalizer.php @@ -2,29 +2,29 @@ namespace Kettasoft\Filterable\Support; -use Illuminate\Support\Arr; - class ConditionNormalizer { - /** - * Normalize condition to [ operator => value ]. - * @param string|array|null $condition - * @param string $operator - * @return array - */ - public static function normalize(string|array|null $condition, string|null $operator = null): array - { - if (is_string($condition)) { - // If the condition is a string, we assume it's a value and use the operator. - return ['operator' => $operator, 'value' => $condition]; - } + /** + * Normalize condition to [ operator => value ]. + * + * @param string|array|null $condition + * @param string $operator + * + * @return array + */ + public static function normalize(string|array|null $condition, ?string $operator = null): array + { + if (is_string($condition)) { + // If the condition is a string, we assume it's a value and use the operator. + return ['operator' => $operator, 'value' => $condition]; + } - if (is_array($condition) && !array_is_list($condition)) { - // If the condition is an associative array, we assume it already has the operator as a key. - return [ - 'operator' => array_key_first($condition), - 'value' => array_values($condition)[0] ?? null - ]; + if (is_array($condition) && !array_is_list($condition)) { + // If the condition is an associative array, we assume it already has the operator as a key. + return [ + 'operator' => array_key_first($condition), + 'value' => array_values($condition)[0] ?? null, + ]; + } } - } } diff --git a/src/Support/FilterResolver.php b/src/Support/FilterResolver.php index 2a99a31..92716f4 100644 --- a/src/Support/FilterResolver.php +++ b/src/Support/FilterResolver.php @@ -2,9 +2,9 @@ namespace Kettasoft\Filterable\Support; -use Illuminate\Support\Facades\App; -use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\App; use Illuminate\Support\Traits\ForwardsCalls; use Kettasoft\Filterable\Contracts\FilterableContext; use Kettasoft\Filterable\Exceptions\FilterIsNotDefinedException; @@ -13,74 +13,81 @@ class FilterResolver { - use ForwardsCalls; - - /** - * Query builder instance. - * @var Builder - */ - protected Builder $builder; + use ForwardsCalls; - /** - * Filterable instance. - * @var FilterableContext|string - */ - protected $filter; + /** + * Query builder instance. + * + * @var Builder + */ + protected Builder $builder; - /** - * Create FilterRegisterator instance. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder - * @param mixed $filter - */ - public function __construct(Builder $builder, $filter = null) - { - $this->builder = $builder; - $this->filter = $filter; - } + /** + * Filterable instance. + * + * @var FilterableContext|string + */ + protected $filter; - /** - * Bind the filter instance to model. - * @throws \Kettasoft\Filterable\Exceptions\FilterIsNotDefinedException - * @return QueryBuilderInterface - */ - public function resolve(): QueryBuilderInterface|Filterable - { - if ($this->filter instanceof FilterableContext) { - return $this->forwardCallTo($this->filter, 'apply', [$this->builder]); + /** + * Create FilterRegisterator instance. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * @param mixed $filter + */ + public function __construct(Builder $builder, $filter = null) + { + $this->builder = $builder; + $this->filter = $filter; } - if (is_string($this->filter) && $filter = config('filterable.aliases')[$this->filter] ?? null) { - return $this->apply($filter); - } + /** + * Bind the filter instance to model. + * + * @throws \Kettasoft\Filterable\Exceptions\FilterIsNotDefinedException + * + * @return QueryBuilderInterface + */ + public function resolve(): QueryBuilderInterface|Filterable + { + if ($this->filter instanceof FilterableContext) { + return $this->forwardCallTo($this->filter, 'apply', [$this->builder]); + } - if (is_a($this->filter, FilterableContext::class, true)) { - return $this->apply($this->filter); - } + if (is_string($this->filter) && $filter = config('filterable.aliases')[$this->filter] ?? null) { + return $this->apply($filter); + } + + if (is_a($this->filter, FilterableContext::class, true)) { + return $this->apply($this->filter); + } + + if ($this->filter === null && $filter = $this->getModel()->getFilterable()) { + return $this->apply($filter); + } - if ($this->filter === null && $filter = $this->getModel()->getFilterable()) { - return $this->apply($filter); + throw new FilterIsNotDefinedException($this->filter); } - throw new FilterIsNotDefinedException($this->filter); - } + /** + * Apply the filter to the query builder. + * + * @param mixed $filter + */ + protected function apply($filter) + { + $filter = App::make($filter); - /** - * Apply the filter to the query builder. - * - * @param mixed $filter - */ - protected function apply($filter) - { - $filter = App::make($filter); - return $this->forwardCallTo($filter, 'apply', [$this->builder]); - } + return $this->forwardCallTo($filter, 'apply', [$this->builder]); + } - /** - * Get the model instance being queried. - * @return Builder|Model - */ - public function getModel(): Builder|Model - { - return $this->builder->getModel(); - } + /** + * Get the model instance being queried. + * + * @return Builder|Model + */ + public function getModel(): Builder|Model + { + return $this->builder->getModel(); + } } diff --git a/src/Support/Payload.php b/src/Support/Payload.php index e1e5c4e..450cca8 100644 --- a/src/Support/Payload.php +++ b/src/Support/Payload.php @@ -3,10 +3,10 @@ namespace Kettasoft\Filterable\Support; use Carbon\Carbon; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Jsonable; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; -use Illuminate\Contracts\Support\Jsonable; -use Illuminate\Contracts\Support\Arrayable; /** * @template TKey of array-key @@ -14,637 +14,663 @@ */ class Payload implements \Stringable, Arrayable, Jsonable { - use Macroable; - - /** - * Request field. - * @var string - */ - public string $field; - - /** - * Requested operator. - * @var string - */ - public string $operator; - - /** - * Request value. - * @var mixed - */ - public mixed $value; - - /** - * Value before sanitizing. - * @var mixed - */ - public mixed $rawValue; - - /** - * Create new Payload instance. - * @param string $field - * @param string $operator - * @param mixed $value - * @param mixed $rawValue - */ - public function __construct(string $field, string $operator, mixed $value, mixed $rawValue) - { - $this->field = $field; - $this->operator = $operator; - $this->value = $value; - $this->rawValue = $rawValue; - } - - /** - * Shortcut to create Payload instance. - * @param mixed $field - * @param mixed $operator - * @param mixed $value - * @param mixed $rawValue - * @return Payload - */ - public static function create($field, $operator, $value, $rawValue): static - { - return new static($field, $operator, $value, $rawValue); - } - - /** - * Get the original unmodified value. - * - * @return mixed - */ - public function raw(): mixed - { - return $this->rawValue; - } - - /** - * Get the length of the payload value. - * - * @return int - */ - public function length(): int - { - return is_string($this->value) ? mb_strlen($this->value) : count((array) $this->value); - } - - /** - * Check if the payload is empty. - * - * @return bool - */ - public function isEmpty(): bool - { - return empty($this->value); - } - - /** - * Check if the payload is an empty string. - * - * @return bool - */ - public function isEmptyString(): bool - { - return is_string($this->value) && trim($this->value) === ''; - } - - /** - * Check if the payload is not null or empty. - * - * @return bool - */ - public function isNotNullOrEmpty(): bool - { - return !$this->isNull() && !$this->isEmpty() && !$this->isEmptyString(); - } - - /** - * Check if the payload is not empty. - * - * @return bool - */ - public function isNotEmpty(): bool - { - return !$this->isEmpty(); - } - - /** - * Check if the payload value is null. - * - * @return bool - */ - public function isNull(): bool - { - return is_null($this->value); - } - - /** - * Check if the payload value is a boolean. - * - * @return bool - */ - public function isBoolean(): bool - { - if (is_bool($this->value)) { - return true; - } - - return filter_var($this->value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null; - } - - /** - * Check if the payload value is a valid JSON string. - * - * @param bool $strict When true, only JSON objects/arrays are considered valid. - * When false, any valid JSON (string, number, boolean, null, object, array) is accepted. - * @return bool - */ - public function isJson(bool $strict = true): bool - { - if (!is_string($this->value)) { - return false; - } - - $decoded = json_decode($this->value, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - return false; - } - - if ($strict) { - return is_array($decoded); - } - - return true; - } - - /** - * Check if the payload value is numeric. - * - * @return bool - */ - public function isNumeric(): bool - { - return is_numeric($this->value); - } - - /** - * Check if the payload value is a string. - * - * @return bool - */ - public function isString(): bool - { - return is_string($this->value); - } - - /** - * Check if the payload value is an array. - * - * @return bool - */ - public function isArray(): bool - { - return is_array($this->value); - } - - /** - * Check if the payload value is true. - * - * @return bool - */ - public function isTrue(): bool - { - return $this->isBoolean() && filter_var($this->value, FILTER_VALIDATE_BOOLEAN) === true; - } - - /** - * Check if the payload value is false. - * - * @return bool - */ - public function isFalse(): bool - { - return $this->isBoolean() && filter_var($this->value, FILTER_VALIDATE_BOOLEAN) === false; - } - - /** - * Check if the payload value is a valid date. - * - * @return bool - */ - public function isDate(): bool - { - if (! $this->isString()) { - return false; - } - - return strtotime($this->value) !== false; - } - - /** - * Check if the payload value is a valid timestamp. - * - * @return bool - */ - public function isTimestamp(): bool - { - return $this->isNumeric() && (int) $this->value > 0; - } - - /** - * Get the payload value as a Carbon instance. - * - * @return Carbon|null - */ - public function asCarbon(): Carbon|null - { - if ($this->isTimestamp()) { - return Carbon::createFromTimestamp((int) $this->value); - } - - if ($this->isDate()) { - return Carbon::parse($this->value); - } - - return null; - } - - /** - * Check if the payload value matches the given regex pattern. - * - * @param string $pattern - * @return bool - */ - public function regex(string $pattern): bool - { - if (! $this->isString()) { - return false; - } - - return preg_match($pattern, $this->value) === 1; - } - - /** - * Get the payload value as a boolean. - * - * Returns `true` or `false` if the value can be interpreted as a boolean - * (e.g. actual bool, "true", "false", 1, 0, "1", "0"), otherwise returns null. - * - * @return bool|null - */ - public function asBoolean(): ?bool - { - return $this->isBoolean() ? filter_var($this->value, FILTER_VALIDATE_BOOLEAN) : null; - } - - /** - - * Convert the payload value to a slug. - * - * @param string $operator - * @return string - */ - public function asSlug(string $operator = '-'): string - { - $value = (string) $this->value; - - return Str::slug($value, $operator); - } - - /** - * Check if the payload value is in the given haystack. - * - * @param mixed ...$haystack - * @return bool - */ - public function in(...$haystack): bool - { - if (count($haystack) === 1 && is_array($haystack[0])) { - $haystack = $haystack[0]; - } - - return in_array($this->value, (array) $haystack, true); - } - - /** - * Check if the payload value is not in the given haystack. - * - * @param mixed ...$haystack - * @return bool - */ - public function notIn(...$haystack): bool - { - return !$this->in(...$haystack); - } - - /** - * Perform multiple is* checks on the payload. - * - * Example: $payload->is('isJson', 'isNotEmpty') - * - * @param mixed ...$checks - * @return bool - * - * @throws \InvalidArgumentException if any of the check methods do not exist. - */ - public function is(...$checks): bool - { - foreach ($checks as $check) { - $negate = false; - - // If there is a ! at the beginning, negate the result - if (str_starts_with($check, '!')) { - $negate = true; - $check = substr($check, 1); // Remove the '!' for method name - } - - $method = 'is' . ucfirst($check); - - if (!method_exists($this, $method)) { - throw new \InvalidArgumentException("Method $method does not exist"); - } - - $result = $this->$method(); - - if ($negate) { - $result = !$result; - } - - if (!$result) { - return false; // Any check failed, return false immediately - } - } - - return true; - } - - /** - * Perform multiple is* checks on the payload, returning true if any pass. - * - * Example: $payload->isAny('isJson', 'isNotEmpty') - * - * @param mixed ...$checks - * @return bool - * - * @throws \InvalidArgumentException if any of the check methods do not exist. - */ - public function isAny(...$checks): bool - { - foreach ($checks as $check) { - $negate = false; - - // If there is a ! at the beginning, negate the result - if (str_starts_with($check, '!')) { - $negate = true; - $check = substr($check, 1); // Remove the '!' for method name - } - - $method = 'is' . ucfirst($check); - - if (!method_exists($this, $method)) { - throw new \InvalidArgumentException("Method $method does not exist"); - } - - $result = $this->$method(); - - if ($negate) { - $result = !$result; - } - - if ($result) { - return true; // Any check passed, return true immediately - } - } - - return false; - } - - /** - * Return a new Payload instance with the given value. - * - * @param mixed $value - * @return Payload - */ - public function setValue(mixed $value): Payload - { - $this->value = $value; - return $this; - } - - /** - * Set the payload field. - * - * @param string $field - * @return Payload - */ - public function setField(string $field): Payload - { - $this->field = $field; - return $this; - } - - /** - * Set the payload operator. - * - * @param string $operator - * @return Payload - */ - public function setOperator(string $operator): Payload - { - $this->operator = $operator; - return $this; - } - - /** - * Get the payload field. - * - * @return string - */ - public function getField(): string - { - return $this->field; - } - - /** - * Get the payload operator. - * - * @return string - */ - public function getOperator(): string - { - return $this->operator; - } - - /** - * Get the payload value as an array. - * - * If the value is a valid JSON string representing an array/object, - * it will be decoded into an array. If the value is already an array, - * it will be returned directly. Otherwise returns null. - * - * @return array|null - */ - public function asArray(): ?array - { - return $this->isJson() ? json_decode($this->value, true) : (is_array($this->value) ? $this->value : null); - } - - /** - * Get the payload value as an integer. - * - * If the value is numeric, it will be cast to int. Otherwise returns null. - * - * @return int|null - */ - public function asInt(): ?int - { - return $this->isNumeric() ? (int) $this->value : null; - } - - /** - * Explode the payload value into an array using the given delimiter. - * - * If the value is a string, it will be split by the delimiter. - * If the value is already an array, it will be returned as is. - * - * @param string $delimiter The delimiter to split the string by. - * @param bool $overwrite Whether to replace the original payload value. Defaults to false. - * @return array - */ - public function explode(string $delimiter = ',', bool $overwrite = false): array - { - if ($this->isArray()) { - return (array) $this->value; - } - - if ($this->isString()) { - $exploded = explode($delimiter, $this->value); - - if ($overwrite) { - $this->value = $exploded; - } - - return $exploded; - } - - // If value is neither string nor array, just return it as-is - return (array) $this->value; - } - - /** - * Alias for explode method. - * - * @param string $delimiter The delimiter to split the string by. - * @param bool $overwrite Whether to replace the original payload value. Defaults to false. - * @return array - */ - public function split(string $delimiter = ',', bool $overwrite = false): array - { - return $this->explode($delimiter, $overwrite); - } - - /** - * Cast the payload value to the given type using the corresponding as* method. - * - * Supported types: 'boolean', 'array', 'int', 'carbon', 'slug', 'like', 'json'. - * - * Example: $payload->cast('int'), $payload->cast('boolean') - * - * @param string $type - * @param mixed ...$args Additional arguments to pass to the cast method. - * @return mixed - * - * @throws \InvalidArgumentException if the cast type method does not exist. - */ - public function cast(string $type, mixed ...$args): mixed - { - $method = 'as' . ucfirst($type); - - if (!method_exists($this, $method)) { - throw new \InvalidArgumentException("Cast type [{$type}] is not supported. Method {$method} does not exist."); - } - - $this->value = $this->$method(...$args); - - return $this->value; - } - - /** - * Alias for cast method. - * - * @param string $type - * @param mixed ...$args Additional arguments to pass to the cast method. - * @return mixed - * - * @throws \InvalidArgumentException if the cast type method does not exist. - */ - public function as(string $type, mixed ...$args): mixed - { - return $this->cast($type, ...$args); - } - - /** - * Wrap the value with a given prefix and suffix. - * - * @param string $prefix - * @param string $suffix - * @return string - */ - protected function wrap(string $prefix, string $suffix): string - { - return sprintf('%s%s%s', $prefix, $this->value, $suffix); - } - - /** - * Get the value wrapped for a LIKE query. - * - * Example: "%value%", "value%", "%value", etc. - * - * @param string $side - * @return string - */ - public function asLike(string $side = 'both'): string - { - return match ($side) { - 'both' => $this->wrap('%', '%'), - 'start' => $this->wrap('%', ''), - 'end' => $this->wrap('', '%'), - default => throw new \InvalidArgumentException(sprintf("The side value is not valid. valid sides: %s, %s, %s", 'both', 'start', 'end')) - }; - } - - /** - * Get the instance as an array. - * - * @return array - */ - public function toArray() - { - return [ - 'field' => $this->field, - 'operator' => $this->operator, - 'value' => $this->value, - 'rawValue' => $this->rawValue, - ]; - } - - /** - * Convert the object to its JSON representation. - * - * @param int $options - * @return string - */ - public function toJson($options = 0) - { - return json_encode($this->toArray(), $options); - } - - /** - * Return request value on read class as a string. - */ - public function __toString() - { - return $this->value; - } + use Macroable; + + /** + * Request field. + * + * @var string + */ + public string $field; + + /** + * Requested operator. + * + * @var string + */ + public string $operator; + + /** + * Request value. + * + * @var mixed + */ + public mixed $value; + + /** + * Value before sanitizing. + * + * @var mixed + */ + public mixed $rawValue; + + /** + * Create new Payload instance. + * + * @param string $field + * @param string $operator + * @param mixed $value + * @param mixed $rawValue + */ + public function __construct(string $field, string $operator, mixed $value, mixed $rawValue) + { + $this->field = $field; + $this->operator = $operator; + $this->value = $value; + $this->rawValue = $rawValue; + } + + /** + * Shortcut to create Payload instance. + * + * @param mixed $field + * @param mixed $operator + * @param mixed $value + * @param mixed $rawValue + * + * @return Payload + */ + public static function create($field, $operator, $value, $rawValue): static + { + return new static($field, $operator, $value, $rawValue); + } + + /** + * Get the original unmodified value. + * + * @return mixed + */ + public function raw(): mixed + { + return $this->rawValue; + } + + /** + * Get the length of the payload value. + * + * @return int + */ + public function length(): int + { + return is_string($this->value) ? mb_strlen($this->value) : count((array) $this->value); + } + + /** + * Check if the payload is empty. + * + * @return bool + */ + public function isEmpty(): bool + { + return empty($this->value); + } + + /** + * Check if the payload is an empty string. + * + * @return bool + */ + public function isEmptyString(): bool + { + return is_string($this->value) && trim($this->value) === ''; + } + + /** + * Check if the payload is not null or empty. + * + * @return bool + */ + public function isNotNullOrEmpty(): bool + { + return !$this->isNull() && !$this->isEmpty() && !$this->isEmptyString(); + } + + /** + * Check if the payload is not empty. + * + * @return bool + */ + public function isNotEmpty(): bool + { + return !$this->isEmpty(); + } + + /** + * Check if the payload value is null. + * + * @return bool + */ + public function isNull(): bool + { + return is_null($this->value); + } + + /** + * Check if the payload value is a boolean. + * + * @return bool + */ + public function isBoolean(): bool + { + if (is_bool($this->value)) { + return true; + } + + return filter_var($this->value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null; + } + + /** + * Check if the payload value is a valid JSON string. + * + * @param bool $strict When true, only JSON objects/arrays are considered valid. + * When false, any valid JSON (string, number, boolean, null, object, array) is accepted. + * + * @return bool + */ + public function isJson(bool $strict = true): bool + { + if (!is_string($this->value)) { + return false; + } + + $decoded = json_decode($this->value, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return false; + } + + if ($strict) { + return is_array($decoded); + } + + return true; + } + + /** + * Check if the payload value is numeric. + * + * @return bool + */ + public function isNumeric(): bool + { + return is_numeric($this->value); + } + + /** + * Check if the payload value is a string. + * + * @return bool + */ + public function isString(): bool + { + return is_string($this->value); + } + + /** + * Check if the payload value is an array. + * + * @return bool + */ + public function isArray(): bool + { + return is_array($this->value); + } + + /** + * Check if the payload value is true. + * + * @return bool + */ + public function isTrue(): bool + { + return $this->isBoolean() && filter_var($this->value, FILTER_VALIDATE_BOOLEAN) === true; + } + + /** + * Check if the payload value is false. + * + * @return bool + */ + public function isFalse(): bool + { + return $this->isBoolean() && filter_var($this->value, FILTER_VALIDATE_BOOLEAN) === false; + } + + /** + * Check if the payload value is a valid date. + * + * @return bool + */ + public function isDate(): bool + { + if (!$this->isString()) { + return false; + } + + return strtotime($this->value) !== false; + } + + /** + * Check if the payload value is a valid timestamp. + * + * @return bool + */ + public function isTimestamp(): bool + { + return $this->isNumeric() && (int) $this->value > 0; + } + + /** + * Get the payload value as a Carbon instance. + * + * @return Carbon|null + */ + public function asCarbon(): ?Carbon + { + if ($this->isTimestamp()) { + return Carbon::createFromTimestamp((int) $this->value); + } + + if ($this->isDate()) { + return Carbon::parse($this->value); + } + + return null; + } + + /** + * Check if the payload value matches the given regex pattern. + * + * @param string $pattern + * + * @return bool + */ + public function regex(string $pattern): bool + { + if (!$this->isString()) { + return false; + } + + return preg_match($pattern, $this->value) === 1; + } + + /** + * Get the payload value as a boolean. + * + * Returns `true` or `false` if the value can be interpreted as a boolean + * (e.g. actual bool, "true", "false", 1, 0, "1", "0"), otherwise returns null. + * + * @return bool|null + */ + public function asBoolean(): ?bool + { + return $this->isBoolean() ? filter_var($this->value, FILTER_VALIDATE_BOOLEAN) : null; + } + + /** + * Convert the payload value to a slug. + * + * @param string $operator + * + * @return string + */ + public function asSlug(string $operator = '-'): string + { + $value = (string) $this->value; + + return Str::slug($value, $operator); + } + + /** + * Check if the payload value is in the given haystack. + * + * @param mixed ...$haystack + * + * @return bool + */ + public function in(...$haystack): bool + { + if (count($haystack) === 1 && is_array($haystack[0])) { + $haystack = $haystack[0]; + } + + return in_array($this->value, (array) $haystack, true); + } + + /** + * Check if the payload value is not in the given haystack. + * + * @param mixed ...$haystack + * + * @return bool + */ + public function notIn(...$haystack): bool + { + return !$this->in(...$haystack); + } + + /** + * Perform multiple is* checks on the payload. + * + * Example: $payload->is('isJson', 'isNotEmpty') + * + * @param mixed ...$checks + * + * @throws \InvalidArgumentException if any of the check methods do not exist. + * + * @return bool + */ + public function is(...$checks): bool + { + foreach ($checks as $check) { + $negate = false; + + // If there is a ! at the beginning, negate the result + if (str_starts_with($check, '!')) { + $negate = true; + $check = substr($check, 1); // Remove the '!' for method name + } + + $method = 'is'.ucfirst($check); + + if (!method_exists($this, $method)) { + throw new \InvalidArgumentException("Method $method does not exist"); + } + + $result = $this->$method(); + + if ($negate) { + $result = !$result; + } + + if (!$result) { + return false; // Any check failed, return false immediately + } + } + + return true; + } + + /** + * Perform multiple is* checks on the payload, returning true if any pass. + * + * Example: $payload->isAny('isJson', 'isNotEmpty') + * + * @param mixed ...$checks + * + * @throws \InvalidArgumentException if any of the check methods do not exist. + * + * @return bool + */ + public function isAny(...$checks): bool + { + foreach ($checks as $check) { + $negate = false; + + // If there is a ! at the beginning, negate the result + if (str_starts_with($check, '!')) { + $negate = true; + $check = substr($check, 1); // Remove the '!' for method name + } + + $method = 'is'.ucfirst($check); + + if (!method_exists($this, $method)) { + throw new \InvalidArgumentException("Method $method does not exist"); + } + + $result = $this->$method(); + + if ($negate) { + $result = !$result; + } + + if ($result) { + return true; // Any check passed, return true immediately + } + } + + return false; + } + + /** + * Return a new Payload instance with the given value. + * + * @param mixed $value + * + * @return Payload + */ + public function setValue(mixed $value): Payload + { + $this->value = $value; + + return $this; + } + + /** + * Set the payload field. + * + * @param string $field + * + * @return Payload + */ + public function setField(string $field): Payload + { + $this->field = $field; + + return $this; + } + + /** + * Set the payload operator. + * + * @param string $operator + * + * @return Payload + */ + public function setOperator(string $operator): Payload + { + $this->operator = $operator; + + return $this; + } + + /** + * Get the payload field. + * + * @return string + */ + public function getField(): string + { + return $this->field; + } + + /** + * Get the payload operator. + * + * @return string + */ + public function getOperator(): string + { + return $this->operator; + } + + /** + * Get the payload value as an array. + * + * If the value is a valid JSON string representing an array/object, + * it will be decoded into an array. If the value is already an array, + * it will be returned directly. Otherwise returns null. + * + * @return array|null + */ + public function asArray(): ?array + { + return $this->isJson() ? json_decode($this->value, true) : (is_array($this->value) ? $this->value : null); + } + + /** + * Get the payload value as an integer. + * + * If the value is numeric, it will be cast to int. Otherwise returns null. + * + * @return int|null + */ + public function asInt(): ?int + { + return $this->isNumeric() ? (int) $this->value : null; + } + + /** + * Explode the payload value into an array using the given delimiter. + * + * If the value is a string, it will be split by the delimiter. + * If the value is already an array, it will be returned as is. + * + * @param string $delimiter The delimiter to split the string by. + * @param bool $overwrite Whether to replace the original payload value. Defaults to false. + * + * @return array + */ + public function explode(string $delimiter = ',', bool $overwrite = false): array + { + if ($this->isArray()) { + return (array) $this->value; + } + + if ($this->isString()) { + $exploded = explode($delimiter, $this->value); + + if ($overwrite) { + $this->value = $exploded; + } + + return $exploded; + } + + // If value is neither string nor array, just return it as-is + return (array) $this->value; + } + + /** + * Alias for explode method. + * + * @param string $delimiter The delimiter to split the string by. + * @param bool $overwrite Whether to replace the original payload value. Defaults to false. + * + * @return array + */ + public function split(string $delimiter = ',', bool $overwrite = false): array + { + return $this->explode($delimiter, $overwrite); + } + + /** + * Cast the payload value to the given type using the corresponding as* method. + * + * Supported types: 'boolean', 'array', 'int', 'carbon', 'slug', 'like', 'json'. + * + * Example: $payload->cast('int'), $payload->cast('boolean') + * + * @param string $type + * @param mixed ...$args Additional arguments to pass to the cast method. + * + * @throws \InvalidArgumentException if the cast type method does not exist. + * + * @return mixed + */ + public function cast(string $type, mixed ...$args): mixed + { + $method = 'as'.ucfirst($type); + + if (!method_exists($this, $method)) { + throw new \InvalidArgumentException("Cast type [{$type}] is not supported. Method {$method} does not exist."); + } + + $this->value = $this->$method(...$args); + + return $this->value; + } + + /** + * Alias for cast method. + * + * @param string $type + * @param mixed ...$args Additional arguments to pass to the cast method. + * + * @throws \InvalidArgumentException if the cast type method does not exist. + * + * @return mixed + */ + public function as(string $type, mixed ...$args): mixed + { + return $this->cast($type, ...$args); + } + + /** + * Wrap the value with a given prefix and suffix. + * + * @param string $prefix + * @param string $suffix + * + * @return string + */ + protected function wrap(string $prefix, string $suffix): string + { + return sprintf('%s%s%s', $prefix, $this->value, $suffix); + } + + /** + * Get the value wrapped for a LIKE query. + * + * Example: "%value%", "value%", "%value", etc. + * + * @param string $side + * + * @return string + */ + public function asLike(string $side = 'both'): string + { + return match ($side) { + 'both' => $this->wrap('%', '%'), + 'start' => $this->wrap('%', ''), + 'end' => $this->wrap('', '%'), + default => throw new \InvalidArgumentException(sprintf('The side value is not valid. valid sides: %s, %s, %s', 'both', 'start', 'end')) + }; + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return [ + 'field' => $this->field, + 'operator' => $this->operator, + 'value' => $this->value, + 'rawValue' => $this->rawValue, + ]; + } + + /** + * Convert the object to its JSON representation. + * + * @param int $options + * + * @return string + */ + public function toJson($options = 0) + { + return json_encode($this->toArray(), $options); + } + + /** + * Return request value on read class as a string. + */ + public function __toString() + { + return $this->value; + } } diff --git a/src/Support/RelationPathParser.php b/src/Support/RelationPathParser.php index 5a0038e..4f223ec 100644 --- a/src/Support/RelationPathParser.php +++ b/src/Support/RelationPathParser.php @@ -4,17 +4,19 @@ class RelationPathParser { - /** - * Splits a field into relation and actual column. - * @param string $relation - * @return array - */ - public static function resolve(string $relation): array - { - $segments = explode('.', $relation); - $field = array_pop($segments); - $path = implode('.', $segments); + /** + * Splits a field into relation and actual column. + * + * @param string $relation + * + * @return array + */ + public static function resolve(string $relation): array + { + $segments = explode('.', $relation); + $field = array_pop($segments); + $path = implode('.', $segments); - return [$path ?: null, $field]; - } + return [$path ?: null, $field]; + } } diff --git a/src/Support/Stub.php b/src/Support/Stub.php index 9dc881e..45bb552 100644 --- a/src/Support/Stub.php +++ b/src/Support/Stub.php @@ -57,9 +57,9 @@ public static function create($path, $replacements) */ public function getPath() { - $path = static::getBasePath() . $this->stub; + $path = static::getBasePath().$this->stub; - return file_exists($path) ? $path : config('filterable.generator.stub') . $this->stub; + return file_exists($path) ? $path : config('filterable.generator.stub').$this->stub; } /** @@ -92,7 +92,7 @@ public function getContents(): array|bool|string $contents = file_get_contents($this->getPath()); foreach ($this->replacements as $search => $replace) { - $contents = str_replace('$$' . strtoupper($search) . '$$', $replace, $contents); + $contents = str_replace('$$'.strtoupper($search).'$$', $replace, $contents); } return $contents; @@ -117,7 +117,7 @@ public function saveTo($path, $filename): bool|int mkdir($path); } - return file_put_contents($path . '/' . $filename, $this->getContents()); + return file_put_contents($path.'/'.$filename, $this->getContents()); } /** diff --git a/src/Support/TreeBasedRelationsResolver.php b/src/Support/TreeBasedRelationsResolver.php index 675b7b3..b4e1053 100644 --- a/src/Support/TreeBasedRelationsResolver.php +++ b/src/Support/TreeBasedRelationsResolver.php @@ -7,42 +7,40 @@ class TreeBasedRelationsResolver { - protected Filterable $context; + protected Filterable $context; - public function __construct(Filterable $context) - { - $this->context = $context; - } - - public function resolve($query, $relation, $field, $operator, $value) - { - if ($this->validate($relation, $field)) { + public function __construct(Filterable $context) + { + $this->context = $context; + } - return $query->whereHas($relation, function ($q) use ($field, $operator, $value) { - $q->where($field, $operator, $value); - }); + public function resolve($query, $relation, $field, $operator, $value) + { + if ($this->validate($relation, $field)) { + return $query->whereHas($relation, function ($q) use ($field, $operator, $value) { + $q->where($field, $operator, $value); + }); + } } - } - protected function validate($relation, $field): bool - { - $relations = $this->context->getRelations(); + protected function validate($relation, $field): bool + { + $relations = $this->context->getRelations(); - if (array_is_list($relations)) { - return in_array($relation, $relations); - } + if (array_is_list($relations)) { + return in_array($relation, $relations); + } - if (array_key_exists($relation, $relations)) { + if (array_key_exists($relation, $relations)) { + $fields = $this->context->getRelations()[$relation]; - $fields = $this->context->getRelations()[$relation]; + return in_array($field, $fields); + } - return in_array($field, $fields); - } + if ($this->context->isStrict()) { + throw new NotAllowedFieldException($field); + } - if ($this->context->isStrict()) { - throw new NotAllowedFieldException($field); + return false; } - - return false; - } } diff --git a/src/Support/TreeBasedSignelConditionResolver.php b/src/Support/TreeBasedSignelConditionResolver.php index 52a322a..e00372a 100644 --- a/src/Support/TreeBasedSignelConditionResolver.php +++ b/src/Support/TreeBasedSignelConditionResolver.php @@ -2,27 +2,25 @@ namespace Kettasoft\Filterable\Support; -use Kettasoft\Filterable\Engines\Contracts\QueryResolverContract; -use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; -use Kettasoft\Filterable\Filterable; - class TreeBasedSignelConditionResolver { - /** - * Resolve query. - * @param mixed $query - * @param mixed $field - * @param mixed $operator - * @param mixed $value - * @return void - */ - public static function resolve($query, $field, $operator, $value) - { - if (in_array($operator, ['in', 'not in']) && is_array($value)) { - $method = $operator === 'in' ? 'whereIn' : 'whereNotIn'; - $query->{$method}($field, $value); - } else { - $query->where($field, $operator, $value); + /** + * Resolve query. + * + * @param mixed $query + * @param mixed $field + * @param mixed $operator + * @param mixed $value + * + * @return void + */ + public static function resolve($query, $field, $operator, $value) + { + if (in_array($operator, ['in', 'not in']) && is_array($value)) { + $method = $operator === 'in' ? 'whereIn' : 'whereNotIn'; + $query->{$method}($field, $value); + } else { + $query->where($field, $operator, $value); + } } - } } diff --git a/src/Support/TreeNode.php b/src/Support/TreeNode.php index 39055a8..6038904 100644 --- a/src/Support/TreeNode.php +++ b/src/Support/TreeNode.php @@ -10,59 +10,62 @@ */ class TreeNode { - /** - * Logical operator(AND/OR) - * @var string - */ - public $logical; + /** + * Logical operator(AND/OR). + * + * @var string + */ + public $logical; - /** - * Children of nodes - * @var array - */ - public array $children = []; + /** + * Children of nodes. + * + * @var array + */ + public array $children = []; - /** - * Field name. - * @var string|null - */ - public string|null $field = null; + /** + * Field name. + * + * @var string|null + */ + public ?string $field = null; - /** - * Operator - * @var string|null - */ - public string|null $operator = null; - public mixed $value = null; + /** + * Operator. + * + * @var string|null + */ + public ?string $operator = null; + public mixed $value = null; - public static function parse($input): TreeNode - { - $node = new self(); + public static function parse($input): TreeNode + { + $node = new self(); + if (isset($input['and']) || isset($input['or'])) { + $node->logical = isset($input['and']) ? 'and' : 'or'; - if (isset($input['and']) || isset($input['or'])) { - $node->logical = isset($input['and']) ? 'and' : 'or'; + $group = $input[$node->logical]; - $group = $input[$node->logical]; + foreach ($group as $child) { + $node->children[] = self::parse($child); + } + } else { + try { + $node->field = $input['field']; + $node->operator = $input['operator']; + $node->value = $input['value']; + } catch (\Throwable $th) { + throw new InvalidDataFormatException(); + } + } - foreach ($group as $child) { - $node->children[] = self::parse($child); - } - } else { - try { - $node->field = $input['field']; - $node->operator = $input['operator']; - $node->value = $input['value']; - } catch (\Throwable $th) { - throw new InvalidDataFormatException; - } + return $node; } - return $node; - } - - public function isGroup(): bool - { - return $this->field === null; - } + public function isGroup(): bool + { + return $this->field === null; + } } diff --git a/src/Support/ValidateTableColumns.php b/src/Support/ValidateTableColumns.php index 1137a0b..ba584cc 100644 --- a/src/Support/ValidateTableColumns.php +++ b/src/Support/ValidateTableColumns.php @@ -2,21 +2,24 @@ namespace Kettasoft\Filterable\Support; -use Illuminate\Support\Facades\Schema; -use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Schema; class ValidateTableColumns { - /** - * Check if column name is exist in specific table. - * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Contracts\Database\Eloquent\Builder $instance - * @param string $column - * @return bool - */ - public static function validate(Model|Builder $instance, string $column): bool - { - $table = $instance instanceof Model ? $instance->getTable() : $instance->getModel()->getTable(); - return in_array($column, Schema::getColumnListing($table)); - } + /** + * Check if column name is exist in specific table. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Contracts\Database\Eloquent\Builder $instance + * @param string $column + * + * @return bool + */ + public static function validate(Model|Builder $instance, string $column): bool + { + $table = $instance instanceof Model ? $instance->getTable() : $instance->getModel()->getTable(); + + return in_array($column, Schema::getColumnListing($table)); + } } diff --git a/src/Support/helpers.php b/src/Support/helpers.php index b98388a..778e008 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -3,14 +3,16 @@ use Illuminate\Http\Request; use Kettasoft\Filterable\Filterable; -if (! function_exists('filterable')) { - /** - * Create a new Filterable instance. - * @param Illuminate\Http\Request|null $request - * @return Filterable - */ - function filterable(Request|null $request = null): Filterable - { - return Filterable::create($request); - } +if (!function_exists('filterable')) { + /** + * Create a new Filterable instance. + * + * @param Illuminate\Http\Request|null $request + * + * @return Filterable + */ + function filterable(?Request $request = null): Filterable + { + return Filterable::create($request); + } } diff --git a/src/Traits/FieldNormalizer.php b/src/Traits/FieldNormalizer.php index 13183ef..2072c29 100644 --- a/src/Traits/FieldNormalizer.php +++ b/src/Traits/FieldNormalizer.php @@ -4,18 +4,20 @@ trait FieldNormalizer { - /** - * Check if normalize field option is enable in engine. - * @return bool - */ - abstract protected function hasNormalizeFieldCondition(): bool; + /** + * Check if normalize field option is enable in engine. + * + * @return bool + */ + abstract protected function hasNormalizeFieldCondition(): bool; - /** - * Normalize incoming field name to lowercase. - * @param mixed $field - */ - public function normalizeField($field) - { - return $this->hasNormalizeFieldCondition() ? strtolower($field) : $field; - } + /** + * Normalize incoming field name to lowercase. + * + * @param mixed $field + */ + public function normalizeField($field) + { + return $this->hasNormalizeFieldCondition() ? strtolower($field) : $field; + } } diff --git a/src/Traits/HasFilterable.php b/src/Traits/HasFilterable.php index 9266026..44292c1 100644 --- a/src/Traits/HasFilterable.php +++ b/src/Traits/HasFilterable.php @@ -2,11 +2,11 @@ namespace Kettasoft\Filterable\Traits; -use Kettasoft\Filterable\Filterable; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\FilterResolver; use Kettasoft\Filterable\Exceptions\FilterClassNotResolvedException; +use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Foundation\Contracts\QueryBuilderInterface; +use Kettasoft\Filterable\Support\FilterResolver; /** * Apply filters dynamically to Eloquent Query. @@ -14,41 +14,45 @@ * This is not a typical Laravel Global Scope. * * @method static \Kettasoft\Filterable\Foundation\Invoker|\Illuminate\Contracts\Database\Eloquent\Builder filter(\Kettasoft\Filterable\Filterable|string|null $filter = null) + * * @mixin \Illuminate\Database\Eloquent\Model */ trait HasFilterable { - /** - * Apply all relevant thread filters. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $query - * @param \Kettasoft\Filterable\Filterable|string|null $filter - * @return \Illuminate\Contracts\Database\Eloquent\Builder - */ - public function scopeFilter(Builder $query, Filterable|string|array|null $filter = null): QueryBuilderInterface - { - return (new FilterResolver($query, $filter))->resolve(); - } - - /** - * Get defined filterable class from model. - * @throws \Kettasoft\Filterable\Exceptions\FilterClassNotResolvedException - */ - public function getFilterable() - { - if (! property_exists($this, 'filterable')) { - throw new FilterClassNotResolvedException(get_class($this)); + /** + * Apply all relevant thread filters. + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $query + * @param \Kettasoft\Filterable\Filterable|string|null $filter + * + * @return \Illuminate\Contracts\Database\Eloquent\Builder + */ + public function scopeFilter(Builder $query, Filterable|string|array|null $filter = null): QueryBuilderInterface + { + return (new FilterResolver($query, $filter))->resolve(); } - return $this->filterable; - } + /** + * Get defined filterable class from model. + * + * @throws \Kettasoft\Filterable\Exceptions\FilterClassNotResolvedException + */ + public function getFilterable() + { + if (!property_exists($this, 'filterable')) { + throw new FilterClassNotResolvedException(get_class($this)); + } + + return $this->filterable; + } - /** - * Get the number of models to return per page. - * - * @return int - */ - public function getPerPage() - { - return config('filterable.paginate_limit') ?? request('perPage', parent::getPerPage()); - } + /** + * Get the number of models to return per page. + * + * @return int + */ + public function getPerPage() + { + return config('filterable.paginate_limit') ?? request('perPage', parent::getPerPage()); + } } diff --git a/src/Traits/HasFilterableCache.php b/src/Traits/HasFilterableCache.php index 96da43b..279fb71 100644 --- a/src/Traits/HasFilterableCache.php +++ b/src/Traits/HasFilterableCache.php @@ -8,75 +8,74 @@ use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; /** - * HasFilterableCache trait + * HasFilterableCache trait. * * Provides caching capabilities to filterable classes. * Allows filters to cache their results with TTL, tags, scopes, and profiles. - * - * @package Kettasoft\Filterable\Traits */ trait HasFilterableCache { /** - * Cache TTL for this filter + * Cache TTL for this filter. * * @var DateTimeInterface|int|null */ protected DateTimeInterface|int|null $cacheTtl = null; /** - * Cache tags for this filter + * Cache tags for this filter. * * @var array */ protected array $cacheTags = []; /** - * Cache scopes for this filter + * Cache scopes for this filter. * * @var array */ protected array $cacheScopes = []; /** - * Cache profile name + * Cache profile name. * * @var string|null */ protected ?string $cacheProfile = null; /** - * Whether caching is enabled for this filter instance + * Whether caching is enabled for this filter instance. * * @var bool */ protected bool $cachingEnabled = false; /** - * Whether to cache forever + * Whether to cache forever. * * @var bool */ protected bool $cacheForever = false; /** - * Conditional caching predicate + * Conditional caching predicate. * * @var callable|null */ protected $cacheWhenCallback = null; /** - * Cache key generator instance + * Cache key generator instance. * * @var CacheKeyGenerator|null */ protected ?CacheKeyGenerator $cacheKeyGenerator = null; /** - * Enable caching with optional TTL + * Enable caching with optional TTL. * * @param DateTimeInterface|int|null $ttl Time to live in seconds or DateTimeInterface + * * @return self */ public function cache(DateTimeInterface|int|null $ttl = null): self @@ -89,9 +88,10 @@ public function cache(DateTimeInterface|int|null $ttl = null): self } /** - * Remember the results with caching + * Remember the results with caching. * * @param DateTimeInterface|int|null $ttl + * * @return self */ public function remember(DateTimeInterface|int|null $ttl = null): self @@ -100,7 +100,7 @@ public function remember(DateTimeInterface|int|null $ttl = null): self } /** - * Cache results forever + * Cache results forever. * * @return self */ @@ -114,21 +114,24 @@ public function cacheForever(): self } /** - * Set cache tags + * Set cache tags. * * @param array $tags + * * @return self */ public function cacheTags(array $tags): self { $this->cacheTags = $tags; + return $this; } /** - * Scope cache by authenticated user + * Scope cache by authenticated user. * * @param int|string|null $userId + * * @return self */ public function scopeByUser(int|string|null $userId = null): self @@ -148,46 +151,53 @@ public function scopeByUser(int|string|null $userId = null): self } /** - * Scope cache by tenant + * Scope cache by tenant. * * @param int|string $tenantId + * * @return self */ public function scopeByTenant(int|string $tenantId): self { $this->cacheScopes['tenant'] = $tenantId; + return $this; } /** - * Add a custom cache scope + * Add a custom cache scope. * * @param string $key - * @param mixed $value + * @param mixed $value + * * @return self */ public function scopeBy(string $key, mixed $value): self { $this->cacheScopes[$key] = $value; + return $this; } /** - * Set multiple cache scopes + * Set multiple cache scopes. * * @param array $scopes + * * @return self */ public function withScopes(array $scopes): self { $this->cacheScopes = array_merge($this->cacheScopes, $scopes); + return $this; } /** - * Use a cache profile + * Use a cache profile. * * @param string $profile + * * @return self */ public function cacheProfile(string $profile): self @@ -212,10 +222,11 @@ public function cacheProfile(string $profile): self } /** - * Cache only when a condition is met + * Cache only when a condition is met. * - * @param bool|callable $condition + * @param bool|callable $condition * @param DateTimeInterface|int|null $ttl + * * @return self */ public function cacheWhen(bool|callable $condition, DateTimeInterface|int|null $ttl = null): self @@ -230,23 +241,24 @@ public function cacheWhen(bool|callable $condition, DateTimeInterface|int|null $ } /** - * Cache unless a condition is met + * Cache unless a condition is met. * - * @param bool|callable $condition + * @param bool|callable $condition * @param DateTimeInterface|int|null $ttl + * * @return self */ public function cacheUnless(bool|callable $condition, DateTimeInterface|int|null $ttl = null): self { if (is_callable($condition)) { - return $this->cacheWhen(fn() => !$condition(), $ttl); + return $this->cacheWhen(fn () => !$condition(), $ttl); } return $this->cacheWhen(!$condition, $ttl); } /** - * Flush all cached results for this filterable + * Flush all cached results for this filterable. * * Flushes cache using the auto-generated class tag, which will clear * all cache entries for this filter regardless of the terminal method used. @@ -256,15 +268,16 @@ public function cacheUnless(bool|callable $condition, DateTimeInterface|int|null public function flushCache(): bool { // Flush by the auto-generated class tag - $classTag = 'filterable:' . Str::slug(class_basename(static::class)); + $classTag = 'filterable:'.Str::slug(class_basename(static::class)); return $this->flushCacheByTags([$classTag]); } /** - * Flush cache by tags + * Flush cache by tags. * * @param array|null $tags + * * @return bool */ public function flushCacheByTags(?array $tags = null): bool @@ -276,23 +289,26 @@ public function flushCacheByTags(?array $tags = null): bool } $manager = app(FilterableCacheManager::class); + return $manager->flushByTags($tags); } /** - * Flush cache by tags (static method) + * Flush cache by tags (static method). * * @param array $tags + * * @return bool */ public static function flushCacheByTagsStatic(array $tags): bool { $manager = app(FilterableCacheManager::class); + return $manager->flushByTags($tags); } /** - * Check if caching is enabled for this instance + * Check if caching is enabled for this instance. * * @return bool */ @@ -312,7 +328,7 @@ public function isCachingEnabled(): bool } /** - * Generate cache key for this filter + * Generate cache key for this filter. * * @return string */ @@ -333,7 +349,7 @@ protected function generateCacheKey(): string } /** - * Get cache key generator instance + * Get cache key generator instance. * * @return CacheKeyGenerator */ @@ -347,9 +363,10 @@ protected function getCacheKeyGenerator(): CacheKeyGenerator } /** - * Execute with caching if enabled + * Execute with caching if enabled. * * @param callable $callback + * * @return mixed */ protected function executeWithCache(callable $callback): mixed @@ -376,7 +393,7 @@ protected function executeWithCache(callable $callback): mixed } /** - * Get cache TTL + * Get cache TTL. * * @return DateTimeInterface|int|null */ @@ -386,20 +403,20 @@ public function getCacheTtl(): DateTimeInterface|int|null } /** - * Get cache tags + * Get cache tags. * * @return array */ public function getCacheTags(): array { // Always include a tag based on the filter class name for easy flushing - $classTag = 'filterable:' . Str::slug(class_basename(static::class)); + $classTag = 'filterable:'.Str::slug(class_basename(static::class)); return array_unique(array_merge([$classTag], $this->cacheTags)); } /** - * Get cache scopes + * Get cache scopes. * * @return array */ @@ -409,7 +426,7 @@ public function getCacheScopes(): array } /** - * Get cache profile + * Get cache profile. * * @return string|null */ @@ -419,7 +436,7 @@ public function getCacheProfile(): ?string } /** - * Reset all cache settings + * Reset all cache settings. * * @return self */ diff --git a/src/Traits/HasFilterableEvents.php b/src/Traits/HasFilterableEvents.php index 346e564..e184445 100644 --- a/src/Traits/HasFilterableEvents.php +++ b/src/Traits/HasFilterableEvents.php @@ -3,31 +3,30 @@ namespace Kettasoft\Filterable\Traits; /** - * Trait HasFilterableEvents - * + * Trait HasFilterableEvents. + * * Provides event listening and firing capabilities for filterable classes. * This trait acts as a thin wrapper that delegates all event management * to the FilterableEventManager singleton instance. - * + * * This design provides backward compatibility while centralizing all event * logic in a dedicated manager class, making the system more maintainable * and testable. - * - * @package Kettasoft\Filterable\Traits - * + * + * * @link https://kettasoft.github.io/filterable/features/events + * * @property \Kettasoft\Filterable\Foundation\Events\Contracts\EventManager $eventManager */ trait HasFilterableEvents { - /** * Register a global event listener. - * + * * This method delegates to the FilterableEventManager singleton, * allowing you to listen to specific lifecycle events across * all filterable instances. - * + * * Available events: * - filterable.initializing: When a new Filterable instance is created * - filterable.resolved: After resolving engine and request data @@ -35,10 +34,10 @@ trait HasFilterableEvents * - filterable.failed: If any exception occurs during apply * - filterable.finished: At the end of filtering lifecycle (finally block) * - filterable.fetched: After data retrieval operations (get, first, paginate, etc.) - * - * @param string $event The event name to listen for (e.g., 'filterable.applied') + * + * @param string $event The event name to listen for (e.g., 'filterable.applied') * @param callable $callback The callback to execute when the event fires. - * + * * @return void */ public static function on(string $event, callable $callback): void @@ -48,16 +47,16 @@ public static function on(string $event, callable $callback): void /** * Register an observer for a specific filter class. - * + * * This method delegates to the FilterableEventManager singleton. * Observers are called only when events are fired from instances of the * specified filter class. - * - * @param string $filterClass The fully qualified filter class name to observe - * @param callable $callback The observer callback. Receives ($event, $payload) where - * $event is the event name (e.g., 'applied') and $payload - * is an array containing the filterable instance and other data. - * + * + * @param string $filterClass The fully qualified filter class name to observe + * @param callable $callback The observer callback. Receives ($event, $payload) where + * $event is the event name (e.g., 'applied') and $payload + * is an array containing the filterable instance and other data. + * * @return void */ public static function observe(string $filterClass, callable $callback): void @@ -67,20 +66,20 @@ public static function observe(string $filterClass, callable $callback): void /** * Fire an event and notify all registered listeners and observers. - * + * * This method is called internally at various points in the filterable lifecycle. * It handles exceptions gracefully to prevent listener failures from breaking * the filtering process. - * + * * The event system can be disabled via configuration ('filterable.events.enabled' => false). * When disabled, this method becomes a no-op. - * - * @param string $event The event name to fire (e.g., 'filterable.applied') - * @param array $payload Additional data to pass to listeners. The filterable - * instance ($this) is automatically prepended. - * + * + * @param string $event The event name to fire (e.g., 'filterable.applied') + * @param array $payload Additional data to pass to listeners. The filterable + * instance ($this) is automatically prepended. + * * @return void - * + * * @internal */ protected function fireEvent(string $event, array $payload = []): void @@ -88,38 +87,39 @@ protected function fireEvent(string $event, array $payload = []): void self::$eventManager->dispatch($event, $payload); } - /** * Enable events for this specific filterable instance. - * + * * This overrides the global configuration setting for this instance only. - * + * * @return static */ public function enableEvents(): static { self::$eventManager->enable(); + return $this; } /** * Disable events for this specific filterable instance. * This overrides the global configuration setting for this instance only. - * + * * @return static */ public function disableEvents(): static { self::$eventManager->disable(); + return $this; } /** * Remove all registered event listeners and observers. - * + * * This is particularly useful in testing scenarios where you want to * ensure a clean state between tests. - * + * * @return void */ public static function flushListeners(): void @@ -129,10 +129,10 @@ public static function flushListeners(): void /** * Reset the event manager instance. - * + * * This method is useful for testing purposes to ensure a fresh * event manager state before each test. - * + * * @return void */ public static function resetEventManager(): void @@ -142,11 +142,11 @@ public static function resetEventManager(): void /** * Get all registered listeners for a specific event. - * + * * This method is primarily intended for testing and debugging purposes. - * + * * @param string $event The event name - * + * * @return array */ public static function getListeners(string $event): array @@ -156,11 +156,11 @@ public static function getListeners(string $event): array /** * Get all registered observers for a specific filter class. - * + * * This method is primarily intended for testing and debugging purposes. - * + * * @param string $filterClass The filter class name - * + * * @return array */ public static function getObservers(string $filterClass): array diff --git a/src/Traits/InteractsWithFilterAuthorization.php b/src/Traits/InteractsWithFilterAuthorization.php index 2a369ef..b1151e4 100644 --- a/src/Traits/InteractsWithFilterAuthorization.php +++ b/src/Traits/InteractsWithFilterAuthorization.php @@ -4,12 +4,13 @@ trait InteractsWithFilterAuthorization { - /** - * Authorization check before running filter operation. - * @return bool - */ - public function authorize(): bool - { - return true; - } + /** + * Authorization check before running filter operation. + * + * @return bool + */ + public function authorize(): bool + { + return true; + } } diff --git a/src/Traits/InteractsWithFilterKey.php b/src/Traits/InteractsWithFilterKey.php index f193cc3..b2daf66 100644 --- a/src/Traits/InteractsWithFilterKey.php +++ b/src/Traits/InteractsWithFilterKey.php @@ -4,29 +4,34 @@ trait InteractsWithFilterKey { - /** - * Filter key to extract data from query string. - * @var string|null - */ - protected $filterKey = 'filter'; + /** + * Filter key to extract data from query string. + * + * @var string|null + */ + protected $filterKey = 'filter'; - /** - * Get a filter key. - * @return string - */ - public function getFilterKey(): string - { - return $this->filterKey ?? config('filterable.filter_key', 'filter'); - } + /** + * Get a filter key. + * + * @return string + */ + public function getFilterKey(): string + { + return $this->filterKey ?? config('filterable.filter_key', 'filter'); + } - /** - * Set a filter key. - * @param string $key - * @return static - */ - public function setFilterKey(string $key): static - { - $this->filterKey = $key; - return $this; - } + /** + * Set a filter key. + * + * @param string $key + * + * @return static + */ + public function setFilterKey(string $key): static + { + $this->filterKey = $key; + + return $this; + } } diff --git a/src/Traits/InteractsWithMethodMentoring.php b/src/Traits/InteractsWithMethodMentoring.php index a9cdcba..79e4bba 100644 --- a/src/Traits/InteractsWithMethodMentoring.php +++ b/src/Traits/InteractsWithMethodMentoring.php @@ -4,30 +4,35 @@ trait InteractsWithMethodMentoring { - /** - * Mentors of filter methods. - * @var array - */ - protected $mentors = []; + /** + * Mentors of filter methods. + * + * @var array + */ + protected $mentors = []; - /** - * Get mentors. - * @return array - */ - public function getMentors(): array - { - return $this->mentors; - } + /** + * Get mentors. + * + * @return array + */ + public function getMentors(): array + { + return $this->mentors; + } - /** - * Set method mentors. - * @param array $mentors - * @param mixed $override - * @return static - */ - public function setMentors(array $mentors, $override = false): static - { - $this->mentors = $override ? $mentors : array_merge($this->mentors, $mentors); - return $this; - } + /** + * Set method mentors. + * + * @param array $mentors + * @param mixed $override + * + * @return static + */ + public function setMentors(array $mentors, $override = false): static + { + $this->mentors = $override ? $mentors : array_merge($this->mentors, $mentors); + + return $this; + } } diff --git a/src/Traits/InteractsWithProvidedData.php b/src/Traits/InteractsWithProvidedData.php index 5a7c2a8..3b39ae2 100644 --- a/src/Traits/InteractsWithProvidedData.php +++ b/src/Traits/InteractsWithProvidedData.php @@ -9,13 +9,14 @@ trait InteractsWithProvidedData { /** * Provided data storage. + * * @var array */ protected static $provided = []; /** * Get provided data. - * + * * @return array */ public static function provides() @@ -25,8 +26,9 @@ public static function provides() /** * Provide data to all Filterable instances. - * + * * @param array $data + * * @return void */ public static function provide(array $data) @@ -36,8 +38,9 @@ public static function provide(array $data) /** * Check if provided data exists by key. - * + * * @param string $key + * * @return bool */ public function hasProvided(string $key): bool @@ -47,12 +50,13 @@ public function hasProvided(string $key): bool /** * Get provided data by key. - * + * * @param string $key - * @param mixed $default + * @param mixed $default + * * @return mixed */ - public function provided(string|null $key = null, $default = null) + public function provided(?string $key = null, $default = null) { if ($key === null) { return self::$provided; diff --git a/src/Traits/InteractsWithRelationsFiltering.php b/src/Traits/InteractsWithRelationsFiltering.php index 11cf9e5..09e89ed 100644 --- a/src/Traits/InteractsWithRelationsFiltering.php +++ b/src/Traits/InteractsWithRelationsFiltering.php @@ -6,89 +6,97 @@ trait InteractsWithRelationsFiltering { - /** - * List of allowed direct relations for filtering. - * @var array string[] - */ - protected $relations = []; - - /** - * Set the allowed direct relations for filtering. - * @param array $relations - * @param mixed $override - */ - public function allowRelations(array $relations, bool $override = false): static - { - $this->relations = $override ? $relations : array_merge($this->relations, $relations); - $this->resources->relations->fill($this->relations); - return $this; - } - - /** - * Set the allowed relations for filtering. - * @param array $relations - * @param bool $override - */ - public function setRelations(array $relations, bool $override = false): static - { - return $this->allowRelations($relations, $override); - } - - /** - * Check if a given relation is allowed for filtering. - * @param string $relation - * @return bool - */ - public function isRelationAllowed(string $relation, $field): bool - { - if (in_array($relation, $this->relations, true)) { - return isset($this->relations[$relation]) ? in_array($field, $this->relations[$relation]) : false; + /** + * List of allowed direct relations for filtering. + * + * @var array string[] + */ + protected $relations = []; + + /** + * Set the allowed direct relations for filtering. + * + * @param array $relations + * @param mixed $override + */ + public function allowRelations(array $relations, bool $override = false): static + { + $this->relations = $override ? $relations : array_merge($this->relations, $relations); + $this->resources->relations->fill($this->relations); + + return $this; } - return false; - } + /** + * Set the allowed relations for filtering. + * + * @param array $relations + * @param bool $override + */ + public function setRelations(array $relations, bool $override = false): static + { + return $this->allowRelations($relations, $override); + } - /** - * Get defined relations. - * @return array - */ - public function getRelations(): array - { - return $this->relations; - } + /** + * Check if a given relation is allowed for filtering. + * + * @param string $relation + * + * @return bool + */ + public function isRelationAllowed(string $relation, $field): bool + { + if (in_array($relation, $this->relations, true)) { + return isset($this->relations[$relation]) ? in_array($field, $this->relations[$relation]) : false; + } + + return false; + } - /** - * Check if the given path is a valid relation path. - * - * @param string $path - * @return bool - */ - public function hasRelationPath(string $path) - { - if (str_contains($path, '.')) { + /** + * Get defined relations. + * + * @return array + */ + public function getRelations(): array + { + return $this->relations; + } - $relations = explode('.', $path); + /** + * Check if the given path is a valid relation path. + * + * @param string $path + * + * @return bool + */ + public function hasRelationPath(string $path) + { + if (str_contains($path, '.')) { + $relations = explode('.', $path); - $field = array_pop($relations); + $field = array_pop($relations); - $path = implode('.', $relations); + $path = implode('.', $relations); - if (Arr::isAssoc($this->relations)) { - return isset($this->relations[$path]) && in_array($field, $this->relations[$path]); - } + if (Arr::isAssoc($this->relations)) { + return isset($this->relations[$path]) && in_array($field, $this->relations[$path]); + } - return in_array($relations[0], $this->relations); + return in_array($relations[0], $this->relations); + } + + return false; } - return false; - } - - /** - * Create Filterable instance with define relations attributes. - * @param array $relations - */ - public static function withRelations(array $relations): static - { - return static::create()->setRelations($relations); - } + /** + * Create Filterable instance with define relations attributes. + * + * @param array $relations + */ + public static function withRelations(array $relations): static + { + return static::create()->setRelations($relations); + } } diff --git a/src/Traits/InteractsWithValidation.php b/src/Traits/InteractsWithValidation.php index 84e12b9..50cd2ca 100644 --- a/src/Traits/InteractsWithValidation.php +++ b/src/Traits/InteractsWithValidation.php @@ -7,30 +7,33 @@ trait InteractsWithValidation { - /** - * Validate incomming request before filtring. - * @throws \Illuminate\Validation\ValidationException - * @return void - */ - public function validate(): void - { - if (empty($this->rules())) { - return; - } + /** + * Validate incomming request before filtring. + * + * @throws \Illuminate\Validation\ValidationException + * + * @return void + */ + public function validate(): void + { + if (empty($this->rules())) { + return; + } - $validator = validator(Arr::only($this->data, array_keys($this->rules())), $this->rules()); + $validator = validator(Arr::only($this->data, array_keys($this->rules())), $this->rules()); - if ($validator->fails()) { - throw new ValidationException($validator); + if ($validator->fails()) { + throw new ValidationException($validator); + } } - } - /** - * Get the validation rules that apply to the filter request. - * @return array - */ - public function rules(): array - { - return []; - } + /** + * Get the validation rules that apply to the filter request. + * + * @return array + */ + public function rules(): array + { + return []; + } } diff --git a/tests/Database/Factories/PostFactory.php b/tests/Database/Factories/PostFactory.php index f5a75d5..84bb54c 100644 --- a/tests/Database/Factories/PostFactory.php +++ b/tests/Database/Factories/PostFactory.php @@ -6,28 +6,28 @@ class PostFactory extends Factory { - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = \Kettasoft\Filterable\Tests\Models\Post::class; + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = \Kettasoft\Filterable\Tests\Models\Post::class; - /** - * Define the model's default state. - * - * @return array - */ - public function definition() - { - return [ - 'title' => $this->faker->word, - 'content' => $this->faker->text, - 'status' => $this->faker->randomElement(['active', 'pending', 'stopped']), - 'views' => $this->faker->numberBetween(0, 1000), - 'is_featured' => $this->faker->boolean, - 'description' => $this->faker->optional()->text, - 'tags' => $this->faker->optional()->randomElements(['php', 'laravel', 'javascript', 'vue'], $this->faker->numberBetween(0, 3)), - ]; - } + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'title' => $this->faker->word, + 'content' => $this->faker->text, + 'status' => $this->faker->randomElement(['active', 'pending', 'stopped']), + 'views' => $this->faker->numberBetween(0, 1000), + 'is_featured' => $this->faker->boolean, + 'description' => $this->faker->optional()->text, + 'tags' => $this->faker->optional()->randomElements(['php', 'laravel', 'javascript', 'vue'], $this->faker->numberBetween(0, 3)), + ]; + } } diff --git a/tests/Database/Factories/TagFactory.php b/tests/Database/Factories/TagFactory.php index 5c45fdc..f5030cd 100644 --- a/tests/Database/Factories/TagFactory.php +++ b/tests/Database/Factories/TagFactory.php @@ -7,23 +7,23 @@ class TagFactory extends Factory { - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = \Kettasoft\Filterable\Tests\Models\Tag::class; + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = \Kettasoft\Filterable\Tests\Models\Tag::class; - /** - * Define the model's default state. - * - * @return array - */ - public function definition() - { - return [ - 'name' => $this->faker->word, - 'post_id' => Post::inRandomOrder()->first('id')->getKey() - ]; - } + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'name' => $this->faker->word, + 'post_id' => Post::inRandomOrder()->first('id')->getKey(), + ]; + } } diff --git a/tests/Database/Factories/UserFactory.php b/tests/Database/Factories/UserFactory.php index 9b5964d..c6190b5 100644 --- a/tests/Database/Factories/UserFactory.php +++ b/tests/Database/Factories/UserFactory.php @@ -21,11 +21,11 @@ class UserFactory extends Factory public function definition() { return [ - 'name' => $this->faker->name, - 'email' => $this->faker->unique()->safeEmail, + 'name' => $this->faker->name, + 'email' => $this->faker->unique()->safeEmail, 'is_blocked' => $this->faker->boolean, - 'platform' => $this->faker->randomElement(['web', 'ios', 'android']), - 'password' => bcrypt('password'), // or use Hash::make('password') + 'platform' => $this->faker->randomElement(['web', 'ios', 'android']), + 'password' => bcrypt('password'), // or use Hash::make('password') ]; } } diff --git a/tests/Database/Migrations/CreatePostsTable.php b/tests/Database/Migrations/CreatePostsTable.php index 4b29e66..ddd2469 100644 --- a/tests/Database/Migrations/CreatePostsTable.php +++ b/tests/Database/Migrations/CreatePostsTable.php @@ -2,39 +2,39 @@ namespace Kettasoft\Filterable\Tests\Database\Migrations; -use Illuminate\Support\Facades\Schema; -use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; class CreatePostsTable extends Migration { - /** - * Run the migrations. - * - * @return void - */ - public function up() - { - Schema::create('posts', function (Blueprint $table) { - $table->id(); - $table->string('title'); - $table->text('content')->nullable(); - $table->enum('status', ['active', 'pending', 'stopped']); - $table->integer('views')->default(0); - $table->boolean('is_featured')->default(false); - $table->text('description')->nullable(); - $table->json('tags')->nullable(); - $table->timestamps(); - }); - } + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('content')->nullable(); + $table->enum('status', ['active', 'pending', 'stopped']); + $table->integer('views')->default(0); + $table->boolean('is_featured')->default(false); + $table->text('description')->nullable(); + $table->json('tags')->nullable(); + $table->timestamps(); + }); + } - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('posts'); - } + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('posts'); + } } diff --git a/tests/Database/Migrations/CreateTagsTable.php b/tests/Database/Migrations/CreateTagsTable.php index b00a7e6..a2ec3f3 100644 --- a/tests/Database/Migrations/CreateTagsTable.php +++ b/tests/Database/Migrations/CreateTagsTable.php @@ -2,35 +2,35 @@ namespace Kettasoft\Filterable\Tests\Database\Migrations; -use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Database\Migrations\Migration; class CreateTagsTable extends Migration { - /** - * Run the migrations. - * - * @return void - */ - public function up() - { - Schema::create('tags', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->foreignIdFor(Post::class)->constrained('posts'); - $table->timestamps(); - }); - } + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('tags', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->foreignIdFor(Post::class)->constrained('posts'); + $table->timestamps(); + }); + } - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('posts'); - } + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('posts'); + } } diff --git a/tests/Database/Migrations/CreateUsersTable.php b/tests/Database/Migrations/CreateUsersTable.php index 7f1160e..71dcb87 100644 --- a/tests/Database/Migrations/CreateUsersTable.php +++ b/tests/Database/Migrations/CreateUsersTable.php @@ -2,9 +2,9 @@ namespace Kettasoft\Filterable\Tests\Database\Migrations; -use Illuminate\Support\Facades\Schema; -use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; class CreateUsersTable extends Migration { diff --git a/tests/Feature/Commands/MakeFilterCommandTest.php b/tests/Feature/Commands/MakeFilterCommandTest.php index 40e64ae..54bbf44 100644 --- a/tests/Feature/Commands/MakeFilterCommandTest.php +++ b/tests/Feature/Commands/MakeFilterCommandTest.php @@ -5,66 +5,66 @@ 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 { + /** + * Setup the test environment. + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); - /** - * Setup the test environment. - * - * @return void - */ - public function setUp(): void - { - parent::setUp(); + config()->set('filterable.generator.stubs', __DIR__.'/../../../stubs/'); + } - config()->set('filterable.generator.stubs', __DIR__ . '/../../../stubs/'); - } + /** + * Clean up the testing environment before the next test. + * + * @return void + */ + protected function tearDown(): void + { + File::deleteDirectory(config('filterable.save_filters_at')); - /** - * Clean up the testing environment before the next test. - * - * @return void - */ - protected function tearDown(): void - { - File::deleteDirectory(config('filterable.save_filters_at')); + parent::tearDown(); + } - parent::tearDown(); - } + /** + * It creates basic filter file. + * + * @test + */ + public function it_creates_basic_filter_file() + { + $filename = 'UserFilter'; - /** - * It creates basic filter file. - * @test - */ - public function it_creates_basic_filter_file() - { - $filename = 'UserFilter'; + $result = Artisan::call('filterable:make-filter', [ + 'name' => $filename, + ]); - $result = Artisan::call("filterable:make-filter", [ - "name" => $filename - ]); + $this->assertEquals(Command::SUCCESS, $result); + $this->assertTrue(File::exists(app_path('Http/Filters')."/$filename.php")); + } - $this->assertEquals(Command::SUCCESS, $result); - $this->assertTrue(File::exists(app_path('Http/Filters') . "/$filename.php")); - } + /** + * It creates filter with methods file. + * + * @test + */ + public function it_creates_filter_with_methods_file() + { + $filename = 'UserFilter'; - /** - * It creates filter with methods file - * @test - */ - public function it_creates_filter_with_methods_file() - { - $filename = 'UserFilter'; + $result = Artisan::call('filterable:make-filter', [ + 'name' => $filename, + '--filters' => 'methods', + ]); - $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->assertEquals(Command::SUCCESS, $result); + $this->assertTrue(File::exists(app_path('Http/Filters')."/$filename.php")); + } } diff --git a/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php index 7f3d6d6..c12d3d2 100644 --- a/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php +++ b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php @@ -7,8 +7,8 @@ class CanMakeFilter implements Authorizable { - public function authorize(): bool - { - return true; - } + public function authorize(): bool + { + return true; + } } diff --git a/tests/Feature/Engines/Attributes/AuthorizeAttributeTest.php b/tests/Feature/Engines/Attributes/AuthorizeAttributeTest.php index acdceff..d8ce513 100644 --- a/tests/Feature/Engines/Attributes/AuthorizeAttributeTest.php +++ b/tests/Feature/Engines/Attributes/AuthorizeAttributeTest.php @@ -11,24 +11,24 @@ class AuthorizeAttributeTest extends TestCase { - public function test_authorize_attribute_can_make_filter() - { - request()->merge([ - 'tags' => 'testing', - ]); + public function test_authorize_attribute_can_make_filter() + { + request()->merge([ + 'tags' => 'testing', + ]); - $class = new class extends Filterable { - protected $filters = ['tags']; + $class = new class() extends Filterable { + protected $filters = ['tags']; - #[Authorize(CanMakeFilter::class)] - public function tags(Payload $payload) - { - $this->builder->where('tags', $payload->value); - } - }; + #[Authorize(CanMakeFilter::class)] + public function tags(Payload $payload) + { + $this->builder->where('tags', $payload->value); + } + }; - $sql = Post::filter($class)->toRawSql(); + $sql = Post::filter($class)->toRawSql(); - $this->assertStringContainsString('where "tags"', $sql); - } + $this->assertStringContainsString('where "tags"', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/BetweenAttributeTest.php b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php index 1df33e6..d424c66 100644 --- a/tests/Feature/Engines/Attributes/BetweenAttributeTest.php +++ b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php @@ -2,163 +2,163 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Between; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Between; +use Kettasoft\Filterable\Tests\TestCase; class BetweenAttributeTest extends TestCase { - public function test_between_attribute_allows_value_within_range() - { - request()->merge([ - 'views' => '50', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - $this->assertStringContainsString('50', $sql); - } - - public function test_between_attribute_allows_value_at_minimum_boundary() - { - request()->merge([ - 'views' => '1', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - } - - public function test_between_attribute_allows_value_at_maximum_boundary() - { - request()->merge([ - 'views' => '100', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - } - - public function test_between_attribute_skips_filter_when_value_below_range() - { - request()->merge([ - 'views' => '0', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Filter should be skipped, no where clause for views - $this->assertStringNotContainsString('"views" =', $sql); - } - - public function test_between_attribute_skips_filter_when_value_above_range() - { - request()->merge([ - 'views' => '200', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Filter should be skipped - $this->assertStringNotContainsString('"views" =', $sql); - } - - public function test_between_attribute_skips_filter_for_non_numeric_value() - { - request()->merge([ - 'views' => 'abc', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1, max: 100)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Filter should be skipped - $this->assertStringNotContainsString('"views" =', $sql); - } - - public function test_between_attribute_works_with_float_values() - { - request()->merge([ - 'views' => '3.5', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Between(min: 1.0, max: 5.0)] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - $this->assertStringContainsString('3.5', $sql); - } + public function test_between_attribute_allows_value_within_range() + { + request()->merge([ + 'views' => '50', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('50', $sql); + } + + public function test_between_attribute_allows_value_at_minimum_boundary() + { + request()->merge([ + 'views' => '1', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } + + public function test_between_attribute_allows_value_at_maximum_boundary() + { + request()->merge([ + 'views' => '100', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } + + public function test_between_attribute_skips_filter_when_value_below_range() + { + request()->merge([ + 'views' => '0', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped, no where clause for views + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_skips_filter_when_value_above_range() + { + request()->merge([ + 'views' => '200', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_skips_filter_for_non_numeric_value() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_works_with_float_values() + { + request()->merge([ + 'views' => '3.5', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1.0, max: 5.0)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('3.5', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php index a67fcc9..d3a4a89 100644 --- a/tests/Feature/Engines/Attributes/CastAttributeTest.php +++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php @@ -2,242 +2,242 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Cast; +use Kettasoft\Filterable\Exceptions\StrictnessException; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Exceptions\StrictnessException; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Cast; +use Kettasoft\Filterable\Tests\TestCase; class CastAttributeTest extends TestCase { - public function test_cast_attribute_casts_value_to_int() - { - request()->merge([ - 'views' => '42', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Cast('int')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('where "views" = 42', $sql); - } - - public function test_cast_attribute_casts_value_to_boolean_true() - { - request()->merge([ - 'is_featured' => 'true', - ]); - - $class = new class extends Filterable { - protected $filters = ['is_featured']; - - #[Cast('boolean')] - public function isFeatured(Payload $payload) - { - $this->builder->where('is_featured', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"is_featured" = 1', $sql); - } - - public function test_cast_attribute_casts_value_to_boolean_false() - { - request()->merge([ - 'is_featured' => 'false', - ]); - - $class = new class extends Filterable { - protected $filters = ['is_featured']; - - #[Cast('boolean')] - public function isFeatured(Payload $payload) - { - $this->builder->where('is_featured', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"is_featured"', $sql); - } - - public function test_cast_attribute_casts_value_to_array_from_json() - { - request()->merge([ - 'tags' => '["php","laravel"]', - ]); - - $class = new class extends Filterable { - protected $filters = ['tags']; - - #[Cast('array')] - public function tags(Payload $payload) - { - $this->builder->whereIn('tags', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"tags" in', $sql); - $this->assertStringContainsString('php', $sql); - $this->assertStringContainsString('laravel', $sql); - } - - public function test_cast_attribute_throws_strictness_exception_for_unsupported_type() - { - $this->expectException(StrictnessException::class); - $this->expectExceptionMessage('Cast type [unsupported] is not supported.'); - - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Cast('unsupported')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - Post::filter($class)->toRawSql(); - } - - public function test_cast_attribute_does_not_throw_for_valid_cast_type() - { - request()->merge([ - 'views' => '100', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Cast('int')] - public function views(Payload $payload) - { - $this->builder->where('views', '>', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('where "views" > 100', $sql); - } - - public function test_cast_attribute_with_slug_type() - { - request()->merge([ - 'title' => 'Hello World Post', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Cast('slug')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('hello-world-post', $sql); - } - - public function test_cast_attribute_with_like_type() - { - request()->merge([ - 'title' => 'Laravel', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Cast('like')] - public function title(Payload $payload) - { - $this->builder->where('title', 'LIKE', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('%Laravel%', $sql); - } - - public function test_cast_attribute_with_empty_value_for_int_returns_null() - { - request()->merge([ - 'views' => '', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Cast('int')] - public function views(Payload $payload) - { - if (!is_null($payload->value)) { - $this->builder->where('views', '=', $payload->value); - } - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Empty non-numeric value should produce null from asInt(), so no where clause added - $this->assertStringNotContainsString('where "views"', $sql); - } - - public function test_cast_attribute_stage_is_transform() - { - $this->assertEquals( - \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value, - Cast::stage() - ); - } - - public function test_cast_attribute_handle_method_directly() - { - $payload = Payload::create('views', '=', '42', '42'); - $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( - payload: $payload - ); - - $cast = new Cast('int'); - $cast->handle($context); - - // handle doesn't throw, the cast is valid - $this->assertTrue(true); - } - - public function test_cast_attribute_handle_throws_for_invalid_type() - { - $this->expectException(StrictnessException::class); - - $payload = Payload::create('status', '=', 'active', 'active'); - $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( - payload: $payload - ); - - $cast = new Cast('nonExistent'); - $cast->handle($context); - } + public function test_cast_attribute_casts_value_to_int() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "views" = 42', $sql); + } + + public function test_cast_attribute_casts_value_to_boolean_true() + { + request()->merge([ + 'is_featured' => 'true', + ]); + + $class = new class() extends Filterable { + protected $filters = ['is_featured']; + + #[Cast('boolean')] + public function isFeatured(Payload $payload) + { + $this->builder->where('is_featured', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"is_featured" = 1', $sql); + } + + public function test_cast_attribute_casts_value_to_boolean_false() + { + request()->merge([ + 'is_featured' => 'false', + ]); + + $class = new class() extends Filterable { + protected $filters = ['is_featured']; + + #[Cast('boolean')] + public function isFeatured(Payload $payload) + { + $this->builder->where('is_featured', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"is_featured"', $sql); + } + + public function test_cast_attribute_casts_value_to_array_from_json() + { + request()->merge([ + 'tags' => '["php","laravel"]', + ]); + + $class = new class() extends Filterable { + protected $filters = ['tags']; + + #[Cast('array')] + public function tags(Payload $payload) + { + $this->builder->whereIn('tags', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"tags" in', $sql); + $this->assertStringContainsString('php', $sql); + $this->assertStringContainsString('laravel', $sql); + } + + public function test_cast_attribute_throws_strictness_exception_for_unsupported_type() + { + $this->expectException(StrictnessException::class); + $this->expectExceptionMessage('Cast type [unsupported] is not supported.'); + + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Cast('unsupported')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + Post::filter($class)->toRawSql(); + } + + public function test_cast_attribute_does_not_throw_for_valid_cast_type() + { + request()->merge([ + 'views' => '100', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + $this->builder->where('views', '>', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "views" > 100', $sql); + } + + public function test_cast_attribute_with_slug_type() + { + request()->merge([ + 'title' => 'Hello World Post', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Cast('slug')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('hello-world-post', $sql); + } + + public function test_cast_attribute_with_like_type() + { + request()->merge([ + 'title' => 'Laravel', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Cast('like')] + public function title(Payload $payload) + { + $this->builder->where('title', 'LIKE', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('%Laravel%', $sql); + } + + public function test_cast_attribute_with_empty_value_for_int_returns_null() + { + request()->merge([ + 'views' => '', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + if (!is_null($payload->value)) { + $this->builder->where('views', '=', $payload->value); + } + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Empty non-numeric value should produce null from asInt(), so no where clause added + $this->assertStringNotContainsString('where "views"', $sql); + } + + public function test_cast_attribute_stage_is_transform() + { + $this->assertEquals( + \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value, + Cast::stage() + ); + } + + public function test_cast_attribute_handle_method_directly() + { + $payload = Payload::create('views', '=', '42', '42'); + $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + payload: $payload + ); + + $cast = new Cast('int'); + $cast->handle($context); + + // handle doesn't throw, the cast is valid + $this->assertTrue(true); + } + + public function test_cast_attribute_handle_throws_for_invalid_type() + { + $this->expectException(StrictnessException::class); + + $payload = Payload::create('status', '=', 'active', 'active'); + $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + payload: $payload + ); + + $cast = new Cast('nonExistent'); + $cast->handle($context); + } } diff --git a/tests/Feature/Engines/Attributes/DefaultValueAttributeTest.php b/tests/Feature/Engines/Attributes/DefaultValueAttributeTest.php index 6f125f7..e674fe9 100644 --- a/tests/Feature/Engines/Attributes/DefaultValueAttributeTest.php +++ b/tests/Feature/Engines/Attributes/DefaultValueAttributeTest.php @@ -2,51 +2,51 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue; +use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class DefaultValueAttributeTest extends TestCase { - public function test_default_value_attribute_applies_default_value_when_none_provided() - { - request()->merge([ - 'status' => '', - ]); - $class = new class extends Filterable { - protected $filters = ['status']; - - #[DefaultValue('defaultValue')] - public function status(Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - $sql = 'select * from "posts" where "name" = \'defaultValue\''; - - $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); - } - - public function test_default_value_attribute_does_not_override_provided_value() - { - request()->merge([ - 'status' => 'kettasoft', - ]); - $class = new class extends Filterable { - protected $filters = ['status']; - - #[DefaultValue('defaultValue')] - public function status(Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - $sql = 'select * from "posts" where "name" = \'kettasoft\''; - - $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); - } + public function test_default_value_attribute_applies_default_value_when_none_provided() + { + request()->merge([ + 'status' => '', + ]); + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[DefaultValue('defaultValue')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts" where "name" = \'defaultValue\''; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } + + public function test_default_value_attribute_does_not_override_provided_value() + { + request()->merge([ + 'status' => 'kettasoft', + ]); + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[DefaultValue('defaultValue')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts" where "name" = \'kettasoft\''; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } } diff --git a/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php index 05dba84..499c83a 100644 --- a/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php +++ b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php @@ -2,9 +2,7 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Cast; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Explode; -use Kettasoft\Filterable\Exceptions\StrictnessException; use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; @@ -12,27 +10,27 @@ class ExplodeAttributeTest extends TestCase { - public function test_explode_attribute_splits_string_into_array() - { - request()->merge([ - 'tags' => 'php,laravel,testing', - ]); + public function test_explode_attribute_splits_string_into_array() + { + request()->merge([ + 'tags' => 'php,laravel,testing', + ]); - $class = new class extends Filterable { - protected $filters = ['tags']; + $class = new class() extends Filterable { + protected $filters = ['tags']; - #[Explode(',')] - public function tags(Payload $payload) - { - $this->builder->whereIn('tags', $payload->value); - } - }; + #[Explode(',')] + public function tags(Payload $payload) + { + $this->builder->whereIn('tags', $payload->value); + } + }; - $sql = Post::filter($class)->toRawSql(); + $sql = Post::filter($class)->toRawSql(); - $this->assertStringContainsString('where "tags" in', $sql); - $this->assertStringContainsString('php', $sql); - $this->assertStringContainsString('laravel', $sql); - $this->assertStringContainsString('testing', $sql); - } + $this->assertStringContainsString('where "tags" in', $sql); + $this->assertStringContainsString('php', $sql); + $this->assertStringContainsString('laravel', $sql); + $this->assertStringContainsString('testing', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/InAttributeTest.php b/tests/Feature/Engines/Attributes/InAttributeTest.php index d94606c..95c0417 100644 --- a/tests/Feature/Engines/Attributes/InAttributeTest.php +++ b/tests/Feature/Engines/Attributes/InAttributeTest.php @@ -2,8 +2,6 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; -use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\In; use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Support\Payload; @@ -12,44 +10,44 @@ class InAttributeTest extends TestCase { - public function test_in_attribute_allows_value_in_allowed_set() - { - request()->merge([ - 'status' => 'allowedValue', - ]); - $class = new class extends Filterable { - protected $filters = ['status']; - - #[In('allowedValue', 'anotherAllowedValue')] - public function status(Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - $sql = 'select * from "posts" where "name" = \'allowedValue\''; - - $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); - } - - public function test_in_attribute_throws_exception_for_value_not_in_allowed_set() - { - request()->merge([ - 'status' => 'stopped', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[In('pending', 'approved', 'rejected')] - public function status(Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - $sql = 'select * from "posts"'; - - $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); - } + public function test_in_attribute_allows_value_in_allowed_set() + { + request()->merge([ + 'status' => 'allowedValue', + ]); + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[In('allowedValue', 'anotherAllowedValue')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts" where "name" = \'allowedValue\''; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } + + public function test_in_attribute_throws_exception_for_value_not_in_allowed_set() + { + request()->merge([ + 'status' => 'stopped', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[In('pending', 'approved', 'rejected')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts"'; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } } diff --git a/tests/Feature/Engines/Attributes/MapValueAttributeTest.php b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php index 3547cb7..d634c92 100644 --- a/tests/Feature/Engines/Attributes/MapValueAttributeTest.php +++ b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php @@ -2,117 +2,117 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\MapValue; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\MapValue; +use Kettasoft\Filterable\Tests\TestCase; class MapValueAttributeTest extends TestCase { - public function test_map_value_attribute_maps_value_to_mapped_value() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[MapValue(['active' => 1, 'inactive' => 0])] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"status" = 1', $sql); - } - - public function test_map_value_attribute_maps_inactive_to_zero() - { - request()->merge([ - 'status' => 'inactive', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[MapValue(['active' => 1, 'inactive' => 0])] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"status" = 0', $sql); - } - - public function test_map_value_attribute_keeps_original_value_when_not_in_map_non_strict() - { - request()->merge([ - 'status' => 'pending', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[MapValue(['active' => 1, 'inactive' => 0])] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'pending'", $sql); - } - - public function test_map_value_attribute_skips_filter_in_strict_mode_when_not_in_map() - { - request()->merge([ - 'status' => 'unknown', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[MapValue(['active' => 1, 'inactive' => 0], strict: true)] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Filter should be skipped entirely, so no where clause - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_map_value_attribute_maps_string_to_string() - { - request()->merge([ - 'status' => 'published', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[MapValue(['published' => 'live', 'draft' => 'hidden'])] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'live'", $sql); - } + public function test_map_value_attribute_maps_value_to_mapped_value() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status" = 1', $sql); + } + + public function test_map_value_attribute_maps_inactive_to_zero() + { + request()->merge([ + 'status' => 'inactive', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status" = 0', $sql); + } + + public function test_map_value_attribute_keeps_original_value_when_not_in_map_non_strict() + { + request()->merge([ + 'status' => 'pending', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'pending'", $sql); + } + + public function test_map_value_attribute_skips_filter_in_strict_mode_when_not_in_map() + { + request()->merge([ + 'status' => 'unknown', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0], strict: true)] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped entirely, so no where clause + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_map_value_attribute_maps_string_to_string() + { + request()->merge([ + 'status' => 'published', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[MapValue(['published' => 'live', 'draft' => 'hidden'])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'live'", $sql); + } } diff --git a/tests/Feature/Engines/Attributes/RegexAttributeTest.php b/tests/Feature/Engines/Attributes/RegexAttributeTest.php index b79aff9..952baf7 100644 --- a/tests/Feature/Engines/Attributes/RegexAttributeTest.php +++ b/tests/Feature/Engines/Attributes/RegexAttributeTest.php @@ -2,160 +2,160 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Regex; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Regex; +use Kettasoft\Filterable\Tests\TestCase; class RegexAttributeTest extends TestCase { - public function test_regex_attribute_allows_matching_value() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Regex('/^[a-z]+$/')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'active'", $sql); - } - - public function test_regex_attribute_skips_filter_when_value_does_not_match() - { - request()->merge([ - 'status' => 'ACTIVE123', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Regex('/^[a-z]+$/')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Filter should be skipped - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_regex_attribute_validates_email_pattern() - { - request()->merge([ - 'title' => 'test@example.com', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'test@example.com'", $sql); - } - - public function test_regex_attribute_skips_filter_for_invalid_email() - { - request()->merge([ - 'title' => 'not-an-email', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"title" =', $sql); - } - - public function test_regex_attribute_validates_numeric_pattern() - { - request()->merge([ - 'views' => '12345', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Regex('/^\d+$/')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - $this->assertStringContainsString('12345', $sql); - } - - public function test_regex_attribute_skips_filter_for_non_numeric_value_with_numeric_pattern() - { - request()->merge([ - 'views' => 'abc', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Regex('/^\d+$/')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"views" =', $sql); - } - - public function test_regex_attribute_validates_slug_pattern() - { - request()->merge([ - 'title' => 'hello-world-post', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); - } + public function test_regex_attribute_allows_matching_value() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Regex('/^[a-z]+$/')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_regex_attribute_skips_filter_when_value_does_not_match() + { + request()->merge([ + 'status' => 'ACTIVE123', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Regex('/^[a-z]+$/')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_regex_attribute_validates_email_pattern() + { + request()->merge([ + 'title' => 'test@example.com', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'test@example.com'", $sql); + } + + public function test_regex_attribute_skips_filter_for_invalid_email() + { + request()->merge([ + 'title' => 'not-an-email', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"title" =', $sql); + } + + public function test_regex_attribute_validates_numeric_pattern() + { + request()->merge([ + 'views' => '12345', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Regex('/^\d+$/')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('12345', $sql); + } + + public function test_regex_attribute_skips_filter_for_non_numeric_value_with_numeric_pattern() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Regex('/^\d+$/')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_regex_attribute_validates_slug_pattern() + { + request()->merge([ + 'title' => 'hello-world-post', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); + } } diff --git a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php index f2aaf8a..90ce876 100644 --- a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php +++ b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php @@ -2,50 +2,50 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Exceptions\StrictnessException; +use Kettasoft\Filterable\Tests\TestCase; class RequiredValueAttributeTest extends TestCase { - public function test_required_value_attribute_throws_exception_when_value_missing() - { - $this->expectException(StrictnessException::class); - $this->expectExceptionMessage("The parameter 'status' is required."); - - request()->merge([ - 'status' => '', - ]); - - $class = new class extends \Kettasoft\Filterable\Filterable { - protected $filters = ['status']; - - #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required] - public function status(\Kettasoft\Filterable\Support\Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql(); - } - - public function test_required_value_attribute_allows_processing_when_value_provided() - { - request()->merge([ - 'status' => 'kettasoft', - ]); - $class = new class extends \Kettasoft\Filterable\Filterable { - protected $filters = ['status']; - - #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required] - public function status(\Kettasoft\Filterable\Support\Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - $sql = 'select * from "posts" where "name" = \'kettasoft\''; - - $this->assertStringContainsString($sql, \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql()); - } + public function test_required_value_attribute_throws_exception_when_value_missing() + { + $this->expectException(StrictnessException::class); + $this->expectExceptionMessage("The parameter 'status' is required."); + + request()->merge([ + 'status' => '', + ]); + + $class = new class() extends \Kettasoft\Filterable\Filterable { + protected $filters = ['status']; + + #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required] + public function status(\Kettasoft\Filterable\Support\Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql(); + } + + public function test_required_value_attribute_allows_processing_when_value_provided() + { + request()->merge([ + 'status' => 'kettasoft', + ]); + $class = new class() extends \Kettasoft\Filterable\Filterable { + protected $filters = ['status']; + + #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required] + public function status(\Kettasoft\Filterable\Support\Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts" where "name" = \'kettasoft\''; + + $this->assertStringContainsString($sql, \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql()); + } } diff --git a/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php index b5ac581..8a10a81 100644 --- a/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php +++ b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php @@ -2,158 +2,158 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize; +use Kettasoft\Filterable\Tests\TestCase; class SanitizeAttributeTest extends TestCase { - public function test_sanitize_attribute_converts_to_lowercase() - { - request()->merge([ - 'status' => 'ACTIVE', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Sanitize('lowercase')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'active'", $sql); - } - - public function test_sanitize_attribute_converts_to_uppercase() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Sanitize('uppercase')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'ACTIVE'", $sql); - } - - public function test_sanitize_attribute_applies_ucfirst() - { - request()->merge([ - 'title' => 'hello world', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Sanitize('ucfirst')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'Hello world'", $sql); - } - - public function test_sanitize_attribute_strips_html_tags() - { - request()->merge([ - 'title' => 'hello world', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Sanitize('strip_tags')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello world'", $sql); - } - - public function test_sanitize_attribute_applies_multiple_rules_in_order() - { - request()->merge([ - 'status' => ' ACTIVE ', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Sanitize('trim', 'strip_tags', 'lowercase')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'active'", $sql); - } - - public function test_sanitize_attribute_converts_to_slug() - { - request()->merge([ - 'title' => 'Hello World Post', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Sanitize('slug')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); - } - - public function test_sanitize_attribute_does_not_affect_non_string_values() - { - request()->merge([ - 'views' => '42', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Sanitize('lowercase')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - } + public function test_sanitize_attribute_converts_to_lowercase() + { + request()->merge([ + 'status' => 'ACTIVE', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Sanitize('lowercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_sanitize_attribute_converts_to_uppercase() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Sanitize('uppercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'ACTIVE'", $sql); + } + + public function test_sanitize_attribute_applies_ucfirst() + { + request()->merge([ + 'title' => 'hello world', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Sanitize('ucfirst')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'Hello world'", $sql); + } + + public function test_sanitize_attribute_strips_html_tags() + { + request()->merge([ + 'title' => 'hello world', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Sanitize('strip_tags')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_sanitize_attribute_applies_multiple_rules_in_order() + { + request()->merge([ + 'status' => ' ACTIVE ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Sanitize('trim', 'strip_tags', 'lowercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_sanitize_attribute_converts_to_slug() + { + request()->merge([ + 'title' => 'Hello World Post', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Sanitize('slug')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); + } + + public function test_sanitize_attribute_does_not_affect_non_string_values() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Sanitize('lowercase')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/ScopeAttributeTest.php b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php index 35e58ce..0304bef 100644 --- a/tests/Feature/Engines/Attributes/ScopeAttributeTest.php +++ b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php @@ -2,102 +2,102 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Scope; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Scope; +use Kettasoft\Filterable\Tests\TestCase; class ScopeAttributeTest extends TestCase { - public function test_scope_attribute_applies_eloquent_scope() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Scope('active')] - public function status(Payload $payload) - { - // The scope is already applied by the attribute. - // This method can add additional logic if needed. - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"status"', $sql); - $this->assertStringContainsString('active', $sql); - } - - public function test_scope_attribute_applies_popular_scope_with_value() - { - request()->merge([ - 'views' => '500', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Scope('popular')] - public function views(Payload $payload) - { - // Scope is applied by the attribute with the payload value. - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views" >=', $sql); - $this->assertStringContainsString('500', $sql); - } - - public function test_scope_attribute_skips_filter_for_non_existent_scope() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[Scope('nonExistentScope')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // The scope does not exist, so the filter should be skipped entirely - // because the InvalidArgumentException is caught by the engine's attempt handler. - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_scope_attribute_works_with_other_attributes() - { - request()->merge([ - 'status' => ' active ', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim] - #[Scope('active')] - public function status(Payload $payload) - { - // Trim runs first (TRANSFORM stage), then Scope (BEHAVIOR stage). - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"status"', $sql); - $this->assertStringContainsString('active', $sql); - } + public function test_scope_attribute_applies_eloquent_scope() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Scope('active')] + public function status(Payload $payload) + { + // The scope is already applied by the attribute. + // This method can add additional logic if needed. + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status"', $sql); + $this->assertStringContainsString('active', $sql); + } + + public function test_scope_attribute_applies_popular_scope_with_value() + { + request()->merge([ + 'views' => '500', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Scope('popular')] + public function views(Payload $payload) + { + // Scope is applied by the attribute with the payload value. + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views" >=', $sql); + $this->assertStringContainsString('500', $sql); + } + + public function test_scope_attribute_skips_filter_for_non_existent_scope() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[Scope('nonExistentScope')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // The scope does not exist, so the filter should be skipped entirely + // because the InvalidArgumentException is caught by the engine's attempt handler. + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_scope_attribute_works_with_other_attributes() + { + request()->merge([ + 'status' => ' active ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim] + #[Scope('active')] + public function status(Payload $payload) + { + // Trim runs first (TRANSFORM stage), then Scope (BEHAVIOR stage). + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status"', $sql); + $this->assertStringContainsString('active', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php index b26be89..a7092f0 100644 --- a/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php +++ b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php @@ -2,182 +2,182 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\SkipIf; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\SkipIf; +use Kettasoft\Filterable\Tests\TestCase; class SkipIfAttributeTest extends TestCase { - public function test_skip_if_attribute_skips_when_value_is_empty() - { - request()->merge([ - 'status' => '', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[SkipIf('empty')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_skip_if_attribute_does_not_skip_when_value_is_not_empty() - { - request()->merge([ - 'status' => 'active', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[SkipIf('empty')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"status\" = 'active'", $sql); - } - - public function test_skip_if_attribute_skips_when_value_is_null() - { - request()->merge([ - 'status' => null, - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[SkipIf('null')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_skip_if_attribute_with_negation_skips_when_value_is_not_numeric() - { - request()->merge([ - 'views' => 'abc', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[SkipIf('!numeric')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - // Should skip because !numeric is true (value is not numeric) - $this->assertStringNotContainsString('"views" =', $sql); - } - - public function test_skip_if_attribute_with_negation_does_not_skip_when_value_is_numeric() - { - request()->merge([ - 'views' => '42', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[SkipIf('!numeric')] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - $this->assertStringContainsString('42', $sql); - } - - public function test_skip_if_attribute_skips_when_value_is_empty_string() - { - request()->merge([ - 'title' => ' ', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[SkipIf('emptyString')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"title" =', $sql); - } - - public function test_skip_if_attribute_multiple_instances_on_same_method() - { - request()->merge([ - 'status' => '', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[SkipIf('empty')] - #[SkipIf('emptyString')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"status" =', $sql); - } - - public function test_skip_if_attribute_skips_when_value_is_boolean() - { - request()->merge([ - 'status' => 'true', - ]); - - $class = new class extends Filterable { - protected $filters = ['status']; - - #[SkipIf('boolean')] - public function status(Payload $payload) - { - $this->builder->where('status', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringNotContainsString('"status" =', $sql); - } + public function test_skip_if_attribute_skips_when_value_is_empty() + { + request()->merge([ + 'status' => '', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_does_not_skip_when_value_is_not_empty() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_null() + { + request()->merge([ + 'status' => null, + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[SkipIf('null')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_with_negation_skips_when_value_is_not_numeric() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[SkipIf('!numeric')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Should skip because !numeric is true (value is not numeric) + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_skip_if_attribute_with_negation_does_not_skip_when_value_is_numeric() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[SkipIf('!numeric')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('42', $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_empty_string() + { + request()->merge([ + 'title' => ' ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[SkipIf('emptyString')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"title" =', $sql); + } + + public function test_skip_if_attribute_multiple_instances_on_same_method() + { + request()->merge([ + 'status' => '', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + #[SkipIf('emptyString')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_boolean() + { + request()->merge([ + 'status' => 'true', + ]); + + $class = new class() extends Filterable { + protected $filters = ['status']; + + #[SkipIf('boolean')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } } diff --git a/tests/Feature/Engines/Attributes/TrimAttributeTest.php b/tests/Feature/Engines/Attributes/TrimAttributeTest.php index aaad96a..e5d93d8 100644 --- a/tests/Feature/Engines/Attributes/TrimAttributeTest.php +++ b/tests/Feature/Engines/Attributes/TrimAttributeTest.php @@ -2,116 +2,116 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim; +use Kettasoft\Filterable\Tests\TestCase; class TrimAttributeTest extends TestCase { - public function test_trim_attribute_trims_whitespace_from_both_sides() - { - request()->merge([ - 'title' => ' hello world ', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Trim] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello world'", $sql); - } - - public function test_trim_attribute_trims_left_only() - { - request()->merge([ - 'title' => ' hello world ', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Trim(side: 'left')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello world '", $sql); - } - - public function test_trim_attribute_trims_right_only() - { - request()->merge([ - 'title' => ' hello world ', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Trim(side: 'right')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = ' hello world'", $sql); - } - - public function test_trim_attribute_trims_custom_characters() - { - request()->merge([ - 'title' => '---hello world---', - ]); - - $class = new class extends Filterable { - protected $filters = ['title']; - - #[Trim(characters: '-')] - public function title(Payload $payload) - { - $this->builder->where('title', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString("\"title\" = 'hello world'", $sql); - } - - public function test_trim_attribute_does_not_affect_non_string_values() - { - request()->merge([ - 'views' => '42', - ]); - - $class = new class extends Filterable { - protected $filters = ['views']; - - #[Trim] - public function views(Payload $payload) - { - $this->builder->where('views', '=', $payload->value); - } - }; - - $sql = Post::filter($class)->toRawSql(); - - $this->assertStringContainsString('"views"', $sql); - } + public function test_trim_attribute_trims_whitespace_from_both_sides() + { + request()->merge([ + 'title' => ' hello world ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Trim] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_trim_attribute_trims_left_only() + { + request()->merge([ + 'title' => ' hello world ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Trim(side: 'left')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world '", $sql); + } + + public function test_trim_attribute_trims_right_only() + { + request()->merge([ + 'title' => ' hello world ', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Trim(side: 'right')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = ' hello world'", $sql); + } + + public function test_trim_attribute_trims_custom_characters() + { + request()->merge([ + 'title' => '---hello world---', + ]); + + $class = new class() extends Filterable { + protected $filters = ['title']; + + #[Trim(characters: '-')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_trim_attribute_does_not_affect_non_string_values() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class() extends Filterable { + protected $filters = ['views']; + + #[Trim] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } } diff --git a/tests/Feature/Filterable/FilterableCommitingFilterTest.php b/tests/Feature/Filterable/FilterableCommitingFilterTest.php index 4fd40fa..0ffdbc3 100644 --- a/tests/Feature/Filterable/FilterableCommitingFilterTest.php +++ b/tests/Feature/Filterable/FilterableCommitingFilterTest.php @@ -11,16 +11,18 @@ class FilterableCommitingFilterTest extends TestCase public function test_it_saving_applied_filters() { request()->merge([ - 'status' => 'active', + 'status' => 'active', 'category' => 'news', ]); - $filterable = new class extends Filterable { + $filterable = new class() extends Filterable { protected $filters = ['status', 'category']; + public function status($payload) { return $this->builder->where('status', $payload->value); } + public function category($payload) { return $this->builder->where('category', $payload->value); diff --git a/tests/Feature/Filterable/FilterableLifecycleHooksTest.php b/tests/Feature/Filterable/FilterableLifecycleHooksTest.php index e128813..a313f7e 100644 --- a/tests/Feature/Filterable/FilterableLifecycleHooksTest.php +++ b/tests/Feature/Filterable/FilterableLifecycleHooksTest.php @@ -2,16 +2,16 @@ namespace Kettasoft\Filterable\Tests\Feature\Filterable; +use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Contracts\Database\Eloquent\Builder; +use Kettasoft\Filterable\Tests\TestCase; class FilterableLifecycleHooksTest extends TestCase { public function test_it_can_trigger_before_filtering_hook() { - $filter = new class extends Filterable { + $filter = new class() extends Filterable { protected function initially(Builder $builder): Builder { return $builder->where('id', '>', 10); @@ -25,7 +25,7 @@ protected function initially(Builder $builder): Builder public function test_it_can_trigger_after_filtering_hook() { - $filter = new class extends Filterable { + $filter = new class() extends Filterable { protected function finally(Builder $builder): Builder { return $builder->where('id', '>', 10); @@ -39,7 +39,7 @@ protected function finally(Builder $builder): Builder public function test_it_can_trigger_initially_with_finally_hook() { - $filter = new class extends Filterable { + $filter = new class() extends Filterable { protected function initially(Builder $builder): Builder { return $builder->where('id', '>', 10); @@ -58,8 +58,9 @@ protected function finally(Builder $builder): Builder public function test_it_can_trigger_initially_with_request_filters_and_finally_hook() { - $filter = new class extends Filterable { + $filter = new class() extends Filterable { protected $filters = ['title']; + protected function initially(Builder $builder): Builder { return $builder->where('id', '>', 10); diff --git a/tests/Feature/Invoker/InvokerSerializationTest.php b/tests/Feature/Invoker/InvokerSerializationTest.php index d57becd..d6f66e1 100644 --- a/tests/Feature/Invoker/InvokerSerializationTest.php +++ b/tests/Feature/Invoker/InvokerSerializationTest.php @@ -7,29 +7,29 @@ class InvokerSerializationTest extends TestCase { - public function test_it_can_be_serialized_and_unserialized() - { - $builder = Post::query(); - $invoker = new \Kettasoft\Filterable\Foundation\Invoker($builder); + public function test_it_can_be_serialized_and_unserialized() + { + $builder = Post::query(); + $invoker = new \Kettasoft\Filterable\Foundation\Invoker($builder); - // Set some callbacks - $invoker->beforeExecute(function () { - return 'before'; - }); - $invoker->afterExecute(function () { - return 'after'; - }); - $invoker->onError(function () { - return 'error'; - }); + // Set some callbacks + $invoker->beforeExecute(function () { + return 'before'; + }); + $invoker->afterExecute(function () { + return 'after'; + }); + $invoker->onError(function () { + return 'error'; + }); - // Serialize the invoker - $serialized = serialize($invoker); + // Serialize the invoker + $serialized = serialize($invoker); - // Unserialize the invoker - $unserializedInvoker = unserialize($serialized); + // Unserialize the invoker + $unserializedInvoker = unserialize($serialized); - // Assert that the unserialized object is an instance of Invoker - $this->assertInstanceOf(\Kettasoft\Filterable\Foundation\Invoker::class, $unserializedInvoker); - } + // Assert that the unserialized object is an instance of Invoker + $this->assertInstanceOf(\Kettasoft\Filterable\Foundation\Invoker::class, $unserializedInvoker); + } } diff --git a/tests/Feature/Profiler/FilterProfilerTest.php b/tests/Feature/Profiler/FilterProfilerTest.php index 264d07b..2dde3a2 100644 --- a/tests/Feature/Profiler/FilterProfilerTest.php +++ b/tests/Feature/Profiler/FilterProfilerTest.php @@ -2,65 +2,61 @@ namespace Kettasoft\Filterable\Tests\Feature\Profiler; -use Illuminate\Support\Facades\DB; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Tag; -use Kettasoft\Filterable\Tests\Models\Post; use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Support\Facades\DB; use Kettasoft\Filterable\Foundation\Profiler\Profiler; -use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; +use Kettasoft\Filterable\Tests\TestCase; class FilterProfilerTest extends TestCase { - public function test_it_triggers_slow_query_event() - { - $triggered = false; - - Profiler::listen('onSlowQuery', function ($query) use (&$triggered) { - $this->assertArrayHasKey('sql', $query); - $this->assertArrayHasKey('time', $query); - $triggered = true; - }); + public function test_it_triggers_slow_query_event() + { + $triggered = false; - $profiler = app(Profiler::class); - $profiler->start(); + Profiler::listen('onSlowQuery', function ($query) use (&$triggered) { + $this->assertArrayHasKey('sql', $query); + $this->assertArrayHasKey('time', $query); + $triggered = true; + }); - // Simulate a slow query manually - $event = new QueryExecuted('select 1', [], 150, DB::connection()); - $this->invokeMethod($profiler, 'addQuery', [$event]); + $profiler = app(Profiler::class); + $profiler->start(); - $this->assertTrue($triggered, 'Slow query event was not triggered'); - } + // Simulate a slow query manually + $event = new QueryExecuted('select 1', [], 150, DB::connection()); + $this->invokeMethod($profiler, 'addQuery', [$event]); - public function test_it_triggers_duplicate_query_event() - { - $triggered = false; + $this->assertTrue($triggered, 'Slow query event was not triggered'); + } - app(Profiler::class)->dispatcher()->listen('onDuplicateQuery', function ($dup) use (&$triggered) { - $this->assertEquals('select 1', $dup['sql']); - $this->assertEquals(2, $dup['count']); - $triggered = true; - }); + public function test_it_triggers_duplicate_query_event() + { + $triggered = false; - $profiler = app(Profiler::class); - $profiler->start(); + app(Profiler::class)->dispatcher()->listen('onDuplicateQuery', function ($dup) use (&$triggered) { + $this->assertEquals('select 1', $dup['sql']); + $this->assertEquals(2, $dup['count']); + $triggered = true; + }); - $event1 = new QueryExecuted('select 1', [], 5, DB::connection()); - $event2 = new QueryExecuted('select 1', [], 7, DB::connection()); + $profiler = app(Profiler::class); + $profiler->start(); - $this->invokeMethod($profiler, 'addQuery', [$event1]); - $this->invokeMethod($profiler, 'addQuery', [$event2]); + $event1 = new QueryExecuted('select 1', [], 5, DB::connection()); + $event2 = new QueryExecuted('select 1', [], 7, DB::connection()); - $this->assertTrue($triggered, 'Duplicate query event was not triggered'); - } + $this->invokeMethod($profiler, 'addQuery', [$event1]); + $this->invokeMethod($profiler, 'addQuery', [$event2]); + $this->assertTrue($triggered, 'Duplicate query event was not triggered'); + } - protected function invokeMethod(&$object, $methodName, array $parameters = []) - { - $reflection = new \ReflectionClass(get_class($object)); - $method = $reflection->getMethod($methodName); - $method->setAccessible(true); + protected function invokeMethod(&$object, $methodName, array $parameters = []) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); - return $method->invokeArgs($object, $parameters); - } + return $method->invokeArgs($object, $parameters); + } } diff --git a/tests/Jobs/TestExecuteFilterJob.php b/tests/Jobs/TestExecuteFilterJob.php index 7f140ab..13e7004 100644 --- a/tests/Jobs/TestExecuteFilterJob.php +++ b/tests/Jobs/TestExecuteFilterJob.php @@ -7,23 +7,25 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use YourPackage\Filterable\Invoker; class TestExecuteFilterJob implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; - public $invoker; - public $extra; + public $invoker; + public $extra; - public function __construct(array $data) - { - $this->invoker = $data['invoker']; - $this->extra = $data['extra'] ?? null; - } + public function __construct(array $data) + { + $this->invoker = $data['invoker']; + $this->extra = $data['extra'] ?? null; + } - public function handle() - { + public function handle() + { // - } + } } diff --git a/tests/Models/Post.php b/tests/Models/Post.php index 9578a79..355aca5 100644 --- a/tests/Models/Post.php +++ b/tests/Models/Post.php @@ -2,43 +2,43 @@ namespace Kettasoft\Filterable\Tests\Models; -use Illuminate\Database\Eloquent\Model; -use Kettasoft\Filterable\Tests\Models\Tag; -use Kettasoft\Filterable\Traits\HasFilterable; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Kettasoft\Filterable\Tests\Database\Factories\PostFactory; +use Kettasoft\Filterable\Traits\HasFilterable; class Post extends Model { - use HasFactory, HasFilterable; - - protected $fillable = ['title', 'status', 'content', 'views', 'is_featured', 'description', 'tags']; - - protected $casts = [ - 'is_featured' => 'boolean', - 'views' => 'integer', - 'tags' => 'array', - ]; - - public function scopeActive(Builder $query, $value = null): Builder - { - return $query->where('status', $value ?? 'active'); - } - - public function scopePopular(Builder $query, $minViews = 100): Builder - { - return $query->where('views', '>=', $minViews); - } - - public function tags(): HasMany - { - return $this->hasMany(Tag::class); - } - - protected static function newFactory(): PostFactory - { - return PostFactory::new(); - } + use HasFactory; + use HasFilterable; + + protected $fillable = ['title', 'status', 'content', 'views', 'is_featured', 'description', 'tags']; + + protected $casts = [ + 'is_featured' => 'boolean', + 'views' => 'integer', + 'tags' => 'array', + ]; + + public function scopeActive(Builder $query, $value = null): Builder + { + return $query->where('status', $value ?? 'active'); + } + + public function scopePopular(Builder $query, $minViews = 100): Builder + { + return $query->where('views', '>=', $minViews); + } + + public function tags(): HasMany + { + return $this->hasMany(Tag::class); + } + + protected static function newFactory(): PostFactory + { + return PostFactory::new(); + } } diff --git a/tests/Models/Tag.php b/tests/Models/Tag.php index a326ecf..cae4ce9 100644 --- a/tests/Models/Tag.php +++ b/tests/Models/Tag.php @@ -2,27 +2,28 @@ namespace Kettasoft\Filterable\Tests\Models; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Kettasoft\Filterable\Tests\Database\Factories\TagFactory; class Tag extends Model { - use HasFactory; + use HasFactory; - /** - * Post fillable. - * @var array - */ - protected $fillable = ['name', 'content', 'status']; + /** + * Post fillable. + * + * @var array + */ + protected $fillable = ['name', 'content', 'status']; - public function post() - { - return $this->belongsTo(Post::class); - } + public function post() + { + return $this->belongsTo(Post::class); + } - protected static function newFactory(): TagFactory - { - return TagFactory::new(); - } + protected static function newFactory(): TagFactory + { + return TagFactory::new(); + } } diff --git a/tests/Models/User.php b/tests/Models/User.php index b41fcd3..4d5ad8e 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -2,15 +2,16 @@ namespace Kettasoft\Filterable\Tests\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Kettasoft\Filterable\Traits\HasFilterable; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Kettasoft\Filterable\Tests\Database\Factories\UserFactory; +use Kettasoft\Filterable\Traits\HasFilterable; class User extends Model { - use HasFactory, HasFilterable; + use HasFactory; + use HasFilterable; protected $fillable = ['name', 'email', 'is_blocked', 'platform', 'password']; diff --git a/tests/TestCase.php b/tests/TestCase.php index 3f750b1..f01f337 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,46 +2,46 @@ namespace Kettasoft\Filterable\Tests; -use Orchestra\Testbench\TestCase as BaseTestCase; -use Kettasoft\Filterable\Tests\Database\Migrations\CreateTagsTable; use Kettasoft\Filterable\Tests\Database\Migrations\CreatePostsTable; +use Kettasoft\Filterable\Tests\Database\Migrations\CreateTagsTable; use Kettasoft\Filterable\Tests\Database\Migrations\CreateUsersTable; +use Orchestra\Testbench\TestCase as BaseTestCase; class TestCase extends BaseTestCase { - public function setUp(): void - { - parent::setUp(); - - $this->migrate(); - } - - protected function getPackageProviders($app) - { - return [\Kettasoft\Filterable\Providers\FilterableServiceProvider::class]; - } - - protected function getEnvironmentSetUp($app) - { - $app['config']->set('cache.default', 'array'); - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - public function migrate() - { - $migrations = [ - CreatePostsTable::class, - CreateTagsTable::class, - CreateUsersTable::class - ]; - - foreach ($migrations as $migration) { - (new $migration)->up(); + public function setUp(): void + { + parent::setUp(); + + $this->migrate(); + } + + protected function getPackageProviders($app) + { + return [\Kettasoft\Filterable\Providers\FilterableServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('cache.default', 'array'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + public function migrate() + { + $migrations = [ + CreatePostsTable::class, + CreateTagsTable::class, + CreateUsersTable::class, + ]; + + foreach ($migrations as $migration) { + (new $migration())->up(); + } } - } } diff --git a/tests/Unit/Engines/EngineManagerTest.php b/tests/Unit/Engines/EngineManagerTest.php index 8dbd104..226eff4 100644 --- a/tests/Unit/Engines/EngineManagerTest.php +++ b/tests/Unit/Engines/EngineManagerTest.php @@ -2,103 +2,109 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Engines\Tree; -use Kettasoft\Filterable\Tests\TestCase; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Engines\Ruleset; use Kettasoft\Filterable\Engines\Expression; -use Kettasoft\Filterable\Engines\Invokable; -use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Factory\EngineManager; +use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Engines\Invokable; +use Kettasoft\Filterable\Engines\Ruleset; +use Kettasoft\Filterable\Engines\Tree; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Tests\TestCase; class EngineManagerTest extends TestCase { - /** - * It can create ruleset engine from engine manager. - * @test - */ - public function it_can_create_ruleset_engine_from_engine_manager() - { - $engine = EngineManager::generate('ruleset', new Filterable()); - - $this->assertInstanceOf(Ruleset::class, $engine); - } - /** - * It can create ruleset engine from engine manager. - * @test - */ - public function it_can_create_tree_engine_from_engine_manager() - { - $engine = EngineManager::generate('tree', new Filterable()); - - $this->assertInstanceOf(Tree::class, $engine); - } - /** - * It can create expression engine from engine manager. - * @test - */ - public function it_can_create_expression_engine_from_engine_manager() - { - $engine = EngineManager::generate('expression', new Filterable()); - - $this->assertInstanceOf(Expression::class, $engine); - } - /** - * It can create ruleset engine from engine manager. - * @test - */ - public function it_can_create_invokeablke_engine_from_engine_manager() - { - $engine = EngineManager::generate('invokable', new Filterable()); - - $this->assertInstanceOf(Invokable::class, $engine); - } - - public function test_it_can_create_custom_engine_from_engine_manager() - { - $engine = new class(new Filterable()) extends Engine { - - public function execute(Builder $builder): Builder - { - return $builder; - } - - protected function isStrictFromConfig(): bool - { - return false; - } - - protected function getAllowedFieldsFromConfig(): array - { - return []; - } - - protected function isIgnoredEmptyValuesFromConfig(): bool - { - return false; - } - - public function getEngineName(): string - { - return false; - } - - public function defaultOperator() - { - return '='; - } - - public function getOperatorsFromConfig(): array - { - return ['=']; - } - }; - - EngineManager::extend('custom', get_class($engine)); - - $engine = EngineManager::generate('custom', new Filterable()); - - $this->assertInstanceOf(Engine::class, $engine); - } + /** + * It can create ruleset engine from engine manager. + * + * @test + */ + public function it_can_create_ruleset_engine_from_engine_manager() + { + $engine = EngineManager::generate('ruleset', new Filterable()); + + $this->assertInstanceOf(Ruleset::class, $engine); + } + + /** + * It can create ruleset engine from engine manager. + * + * @test + */ + public function it_can_create_tree_engine_from_engine_manager() + { + $engine = EngineManager::generate('tree', new Filterable()); + + $this->assertInstanceOf(Tree::class, $engine); + } + + /** + * It can create expression engine from engine manager. + * + * @test + */ + public function it_can_create_expression_engine_from_engine_manager() + { + $engine = EngineManager::generate('expression', new Filterable()); + + $this->assertInstanceOf(Expression::class, $engine); + } + + /** + * It can create ruleset engine from engine manager. + * + * @test + */ + public function it_can_create_invokeablke_engine_from_engine_manager() + { + $engine = EngineManager::generate('invokable', new Filterable()); + + $this->assertInstanceOf(Invokable::class, $engine); + } + + public function test_it_can_create_custom_engine_from_engine_manager() + { + $engine = new class(new Filterable()) extends Engine { + public function execute(Builder $builder): Builder + { + return $builder; + } + + protected function isStrictFromConfig(): bool + { + return false; + } + + protected function getAllowedFieldsFromConfig(): array + { + return []; + } + + protected function isIgnoredEmptyValuesFromConfig(): bool + { + return false; + } + + public function getEngineName(): string + { + return false; + } + + public function defaultOperator() + { + return '='; + } + + public function getOperatorsFromConfig(): array + { + return ['=']; + } + }; + + EngineManager::extend('custom', get_class($engine)); + + $engine = EngineManager::generate('custom', new Filterable()); + + $this->assertInstanceOf(Engine::class, $engine); + } } diff --git a/tests/Unit/Engines/ExpressionEngineTest.php b/tests/Unit/Engines/ExpressionEngineTest.php index c918874..6c1b8b1 100644 --- a/tests/Unit/Engines/ExpressionEngineTest.php +++ b/tests/Unit/Engines/ExpressionEngineTest.php @@ -3,226 +3,234 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; use Illuminate\Http\Request; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Expression; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Tag; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Engines\Expression; +use Kettasoft\Filterable\Tests\Models\Tag; +use Kettasoft\Filterable\Tests\TestCase; use Symfony\Component\HttpFoundation\InputBag; -use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; class ExpressionEngineTest extends TestCase { - public function setUp(): void - { - parent::setUp(); - - $total = 15; - - $posts1 = Post::factory()->create([ - 'status' => 'stopped', - ]); - - Post::factory($total)->create([ - 'status' => 'active', - 'content' => null - ]); - - Post::factory($total)->create([ - 'status' => 'pending', - 'content' => null - ]); - - Tag::factory()->create([ - 'post_id' => $posts1->first()->id, - 'name' => 'stopped' - ]); - } - - /** - * It applies basic ruleset filters correctly. - * @test - */ - public function it_applies_basic_ruleset_filters_correctly() - { - $request = Request::create('/posts?filter[status][eq]=pending'); - - $filter = Filterable::withRequest($request) - ->useEngine('expression') - ->setAllowedFields(['status']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It throw exception when enable engine strict mode with not allowed fields. - * @test - */ - public function it_throw_exception_when_enable_engine_strict_mode_globally_when_has_not_allowed_fields() - { - $request = Request::create('/posts?status=pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields([]) - ->useEngine('expression') - ->apply(Post::query()); - }, NotAllowedFieldException::class); - } - - /** - * It throw exception when enable engine strict mode with not allowed fields. - * @test - */ - public function it_throw_exception_when_enable_engine_strict_mode_locally_when_has_not_allowed_fields() - { - // Disable strict mode globally - config()->set('filterable.engines.ruleset.strict', false); - - $request = Request::create('/posts?status=pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->strict() - ->setAllowedFields([]) - ->useEngine(Expression::class) - ->apply(Post::query()); - }, NotAllowedFieldException::class); - } - - /** - * It can permissive mode locally. - * @test - */ - public function it_can_use_permissive_mode_locally() - { - config()->set('filterable.engines.expression.strict', true); - - $request = Request::create('/posts?status=pending'); - - $filterable = Filterable::withRequest($request) - ->permissive() - ->setAllowedFields([]) - ->useEngine(Expression::class) - ->apply(Post::query()); - - $this->assertEquals(31, $filterable->count()); - } - - /** - * @test - */ - public function it_use_sql_expression_engin_with_relations_test() - { - $request = Request::create('/posts?filter[tags.name]=stopped&filter[status][eq]=stopped'); - - $filter = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->setRelations([ - 'tags' - ]) - ->useEngine('expression') - ->apply(Post::query()); - - $this->assertEquals(1, $filter->count()); - } - - /** - * It applies basic ruleset filters correctly. - * @test - */ - public function it_cant_filtering_with_not_allowed_operators() - { - $request = Request::create('/posts?filter[status][like]=stopped'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine(Expression::class) - ->apply(Post::query()); - }, InvalidOperatorException::class); - } - - /** - * It can use default operator when invalid receved operator - * @test - */ - public function it_can_use_default_operator_when_invalid_receved_operator() - { - config()->set('filterable.engines.expression.strict', false); - $request = Request::create('/posts?filter[status][like]=pending'); - - $filterable = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine('expression') - ->apply(Post::query()); - - $this->assertEquals(15, $filterable->count()); - } - - /** - * It cant use default operator when enabled strict mode. - * @test - */ - public function it_cant_use_default_operator_when_enabled_strict_mode() - { - config()->set('filterable.engines.expression.strict', false); - - $request = Request::create('/posts?filter[status][like]=pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine(Expression::class) - ->strict() - ->apply(Post::query()); - }, InvalidOperatorException::class); - } - - /** - * It can sent json data to filtering operate. - * @test - */ - public function it_can_sent_json_data_to_filtering_operate() - { - config()->set('filterable.engines.ruleset.strict', false); - - $request = Request::create('/posts'); - - $request->setJson(new InputBag([ - 'status' => 'pending' - ])); - - $filter = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->useEngine(Expression::class) - ->strict() - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - public function test_it_sanitize_value_before_applying_to_query() - { - $request = Request::create('/posts'); - - $request->setJson(new InputBag([ - 'status' => 'PENDING' - ])); - - $filter = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->useEngine(Expression::class) - ->setSanitizers([ - 'status' => fn($value) => strtolower($value) - ]) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } + public function setUp(): void + { + parent::setUp(); + + $total = 15; + + $posts1 = Post::factory()->create([ + 'status' => 'stopped', + ]); + + Post::factory($total)->create([ + 'status' => 'active', + 'content' => null, + ]); + + Post::factory($total)->create([ + 'status' => 'pending', + 'content' => null, + ]); + + Tag::factory()->create([ + 'post_id' => $posts1->first()->id, + 'name' => 'stopped', + ]); + } + + /** + * It applies basic ruleset filters correctly. + * + * @test + */ + public function it_applies_basic_ruleset_filters_correctly() + { + $request = Request::create('/posts?filter[status][eq]=pending'); + + $filter = Filterable::withRequest($request) + ->useEngine('expression') + ->setAllowedFields(['status']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It throw exception when enable engine strict mode with not allowed fields. + * + * @test + */ + public function it_throw_exception_when_enable_engine_strict_mode_globally_when_has_not_allowed_fields() + { + $request = Request::create('/posts?status=pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields([]) + ->useEngine('expression') + ->apply(Post::query()); + }, NotAllowedFieldException::class); + } + + /** + * It throw exception when enable engine strict mode with not allowed fields. + * + * @test + */ + public function it_throw_exception_when_enable_engine_strict_mode_locally_when_has_not_allowed_fields() + { + // Disable strict mode globally + config()->set('filterable.engines.ruleset.strict', false); + + $request = Request::create('/posts?status=pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->strict() + ->setAllowedFields([]) + ->useEngine(Expression::class) + ->apply(Post::query()); + }, NotAllowedFieldException::class); + } + + /** + * It can permissive mode locally. + * + * @test + */ + public function it_can_use_permissive_mode_locally() + { + config()->set('filterable.engines.expression.strict', true); + + $request = Request::create('/posts?status=pending'); + + $filterable = Filterable::withRequest($request) + ->permissive() + ->setAllowedFields([]) + ->useEngine(Expression::class) + ->apply(Post::query()); + + $this->assertEquals(31, $filterable->count()); + } + + /** + * @test + */ + public function it_use_sql_expression_engin_with_relations_test() + { + $request = Request::create('/posts?filter[tags.name]=stopped&filter[status][eq]=stopped'); + + $filter = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->setRelations([ + 'tags', + ]) + ->useEngine('expression') + ->apply(Post::query()); + + $this->assertEquals(1, $filter->count()); + } + + /** + * It applies basic ruleset filters correctly. + * + * @test + */ + public function it_cant_filtering_with_not_allowed_operators() + { + $request = Request::create('/posts?filter[status][like]=stopped'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine(Expression::class) + ->apply(Post::query()); + }, InvalidOperatorException::class); + } + + /** + * It can use default operator when invalid receved operator. + * + * @test + */ + public function it_can_use_default_operator_when_invalid_receved_operator() + { + config()->set('filterable.engines.expression.strict', false); + $request = Request::create('/posts?filter[status][like]=pending'); + + $filterable = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine('expression') + ->apply(Post::query()); + + $this->assertEquals(15, $filterable->count()); + } + + /** + * It cant use default operator when enabled strict mode. + * + * @test + */ + public function it_cant_use_default_operator_when_enabled_strict_mode() + { + config()->set('filterable.engines.expression.strict', false); + + $request = Request::create('/posts?filter[status][like]=pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine(Expression::class) + ->strict() + ->apply(Post::query()); + }, InvalidOperatorException::class); + } + + /** + * It can sent json data to filtering operate. + * + * @test + */ + public function it_can_sent_json_data_to_filtering_operate() + { + config()->set('filterable.engines.ruleset.strict', false); + + $request = Request::create('/posts'); + + $request->setJson(new InputBag([ + 'status' => 'pending', + ])); + + $filter = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->useEngine(Expression::class) + ->strict() + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + public function test_it_sanitize_value_before_applying_to_query() + { + $request = Request::create('/posts'); + + $request->setJson(new InputBag([ + 'status' => 'PENDING', + ])); + + $filter = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->useEngine(Expression::class) + ->setSanitizers([ + 'status' => fn ($value) => strtolower($value), + ]) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } } diff --git a/tests/Unit/Engines/InvokableEngineTest.php b/tests/Unit/Engines/InvokableEngineTest.php index 25b2182..52529c0 100644 --- a/tests/Unit/Engines/InvokableEngineTest.php +++ b/tests/Unit/Engines/InvokableEngineTest.php @@ -2,980 +2,984 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; +use Illuminate\Foundation\Testing\RefreshDatabase; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Kettasoft\Filterable\Tests\TestCase; class InvokableEngineTest extends TestCase { - use RefreshDatabase; - - public function setUp(): void - { - parent::setUp(); - - $countOfActiveStatus = 7; - $countOfPendingStatus = 5; - - Post::factory($countOfActiveStatus)->create([ - 'status' => 'active', - 'title' => 'Active posts' - ]); - - Post::factory($countOfPendingStatus)->create([ - 'status' => 'pending', - 'title' => 'Pending posts' - ]); - } - - /** - * It can filter with basic class filter. - * @test - */ - public function it_can_test_method_mapping_filter() - { - request()->merge([ - 'status' => 'pending' - ]); - - $filter = new class extends Filterable { - protected $filters = ['status']; - protected $mentors = [ - 'status' => 'filterBystatus' - ]; - - public function filterBystatus(Payload $payload) - { - return $this->builder->where('status', $payload); - } - }; - - $posts = Post::filter($filter)->count(); - - $this->assertEquals(5, $posts); - } - - /** - * It can filter with basic class filter. - * @test - */ - public function it_filter_with_ignored_null_or_empty_values() - { - request()->merge([ - 'status' => '' - ]); - - $filter = new class extends Filterable { - protected $filters = ['status']; - protected $mentors = [ - 'status' => 'filterBystatus' - ]; - - public function filterBystatus(Payload $payload) - { - if ($payload->value) { - return $this->builder->where('status', $payload); - } + use RefreshDatabase; - return $this->builder->where($payload->field, 'pending'); - } - }; - - $posts = Post::filter($filter)->count(); - - $this->assertEquals(5, $posts); - } - - /** - * It can filter with field and operator. - * @test - */ - public function it_can_filter_with_field_and_operator() - { - request()->merge([ - 'status' => [ - 'operator' => 'eq', - 'value' => 'pending' - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->operator, $payload->value); - } - }; - - $posts = Post::filter($filter)->count(); - - $this->assertEquals(5, $posts); - } - - /** - * @test - */ - public function it_can_filter_with_multiple_filters() - { - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Laravel Tutorial' - ]); - - Post::factory()->create([ - 'status' => 'pending', - 'title' => 'PHP Guide' - ]); - - request()->merge([ - 'status' => 'active', - 'title' => 'Laravel Tutorial' - ]); - - $filter = new class extends Filterable { - protected $filters = ['status', 'title']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - - public function title(Payload $payload) - { - return $this->builder->where('title', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('active', $posts->first()->status); - $this->assertEquals('Laravel Tutorial', $posts->first()->title); - } - - /** - * @test - */ - public function it_can_filter_with_multiple_filters_and_operators() - { - // Clear setUp data for this specific test - Post::truncate(); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Test Post', - 'views' => 100 - ]); - - Post::factory()->create([ - 'status' => 'pending', - 'title' => 'Another Post', - 'views' => 50 - ]); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Third Post', - 'views' => 150 - ]); - - request()->merge([ - 'status' => [ - 'operator' => 'eq', - 'value' => 'active' - ], - 'views' => [ - 'operator' => 'gt', - 'value' => 75 - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status', 'views']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->operator, $payload->value); - } - - public function views(Payload $payload) - { - return $this->builder->where('views', $payload->operator, $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => $post->status === 'active' && $post->views > 75)); - } - - /** - * @test - */ - public function it_can_filter_with_like_operator() - { - Post::truncate(); - - Post::factory()->create(['title' => 'Laravel Framework']); - Post::factory()->create(['title' => 'PHP Tutorial']); - Post::factory()->create(['title' => 'Laravel Tips']); - - request()->merge([ - 'title' => [ - 'operator' => 'like', - 'value' => '%Laravel%' - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['title']; - - public function title(Payload $payload) - { - return $this->builder->where('title', $payload->operator, $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => str_contains($post->title, 'Laravel'))); - } - - /** - * @test - */ - public function it_can_filter_with_in_operator() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active']); - Post::factory()->create(['status' => 'pending']); - Post::factory()->create(['status' => 'stopped']); - - request()->merge([ - 'status' => [ - 'operator' => 'in', - 'value' => ['active', 'stopped'] - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status']; - - public function status(Payload $payload) - { - return $this->builder->whereIn('status', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => in_array($post->status, ['active', 'stopped']))); - } - - /** - * @test - */ - public function it_can_filter_with_between_operator() - { - Post::truncate(); - - Post::factory()->create(['views' => 10]); - Post::factory()->create(['views' => 50]); - Post::factory()->create(['views' => 100]); - Post::factory()->create(['views' => 150]); - - request()->merge([ - 'views' => [ - 'operator' => 'between', - 'value' => [40, 110] - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['views']; - - public function views(Payload $payload) - { - return $this->builder->whereBetween('views', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => $post->views >= 40 && $post->views <= 110)); - } - - /** - * @test - */ - public function it_can_filter_with_null_operator() - { - Post::truncate(); - - Post::factory()->create(['description' => null]); - Post::factory()->create(['description' => 'Some description']); - Post::factory()->create(['description' => null]); - - request()->merge([ - 'description' => [ - 'operator' => 'null', - 'value' => true - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['description']; - - public function description(Payload $payload) - { - if ($payload->value) { - return $this->builder->whereNull('description'); - } + public function setUp(): void + { + parent::setUp(); - return $this->builder->whereNotNull('description'); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => is_null($post->description))); - } - - /** - * @test - */ - public function it_can_handle_camel_case_filter_methods() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active', 'is_featured' => true]); - Post::factory()->create(['status' => 'active', 'is_featured' => false]); - - request()->merge([ - 'is_featured' => true - ]); - - $filter = new class extends Filterable { - protected $filters = ['is_featured']; - - public function isFeatured(Payload $payload) - { - return $this->builder->where('is_featured', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertTrue($posts->first()->is_featured); - } - - /** - * @test - */ - public function it_can_use_method_mentors_mapping() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active']); - Post::factory()->create(['status' => 'pending']); - - request()->merge([ - 'post_status' => 'active' - ]); - - $filter = new class extends Filterable { - protected $filters = ['post_status']; - protected $mentors = [ - 'post_status' => 'filterByStatus' - ]; - - public function filterByStatus(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('active', $posts->first()->status); - } - - /** - * @test - */ - public function it_can_handle_complex_nested_requests() - { - Post::truncate(); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Laravel', - 'views' => 100 - ]); - - Post::factory()->create([ - 'status' => 'pending', - 'title' => 'PHP', - 'views' => 50 - ]); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Vue.js', - 'views' => 150 - ]); - - request()->merge([ - 'status' => [ - 'operator' => '=', - 'value' => 'active' - ], - 'views' => [ - 'operator' => '>=', - 'value' => 100 - ], - 'title' => [ - 'operator' => 'like', - 'value' => '%a%' - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status', 'views', 'title']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->operator, $payload->value); - } - - public function views(Payload $payload) - { - return $this->builder->where('views', $payload->operator, $payload->value); - } - - public function title(Payload $payload) - { - return $this->builder->where('title', $payload->operator, $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('Laravel', $posts->first()->title); - } - - /** - * @test - */ - public function it_can_apply_sanitization_to_filters() - { - Post::truncate(); - - Post::factory()->create(['title' => 'Laravel']); - - request()->merge([ - 'title' => ' Laravel ' - ]); - - $filter = new class extends Filterable { - protected $filters = ['title']; - protected $sanitizers = [ - 'title' => 'trim' - ]; - - public function title(Payload $payload) - { - return $this->builder->where('title', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('Laravel', $posts->first()->title); - } - - /** - * @test - */ - public function it_ignores_empty_values_when_configured() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active']); - Post::factory()->create(['status' => 'pending']); - - request()->merge([ - 'status' => '' - ]); - - $filter = new class extends Filterable { - protected $filters = ['status', 'title']; - protected $ignoreEmptyValues = true; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - }; - - // Should not filter by status since it's empty - $posts = Post::filter($filter)->get(); - - // All posts should be returned since status is ignored - $this->assertGreaterThanOrEqual(2, $posts->count()); - } - - /** - * @test - */ - public function it_can_chain_multiple_where_conditions_in_single_filter() - { - Post::factory()->create(['status' => 'active', 'views' => 100]); - Post::factory()->create(['status' => 'active', 'views' => 50]); - Post::factory()->create(['status' => 'pending', 'views' => 100]); - - request()->merge([ - 'combined' => [ - 'status' => 'active', - 'views' => 100 - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['combined']; - - public function combined(Payload $payload) - { - $data = $payload->value; - return $this->builder - ->where('status', $data['status']) - ->where('views', $data['views']); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('active', $posts->first()->status); - $this->assertEquals(100, $posts->first()->views); - } - - /** - * @test - */ - public function it_can_use_or_where_conditions() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active']); - Post::factory()->create(['status' => 'pending']); - Post::factory()->create(['status' => 'stopped']); - - request()->merge([ - 'status_or' => ['active', 'pending'] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status_or']; - - public function statusOr(Payload $payload) - { - return $this->builder->where(function ($query) use ($payload) { - foreach ($payload->value as $status) { - $query->orWhere('status', $status); - } - }); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - $this->assertTrue($posts->every(fn($post) => in_array($post->status, ['active', 'pending']))); - } - - /** - * @test - */ - public function it_can_handle_boolean_filters() - { - Post::truncate(); - - Post::factory()->create(['is_featured' => true, 'status' => 'active']); - Post::factory()->create(['is_featured' => false, 'status' => 'active']); - Post::factory()->create(['is_featured' => true, 'status' => 'pending']); - - request()->merge([ - 'is_featured' => true, - 'status' => 'active' - ]); - - $filter = new class extends Filterable { - protected $filters = ['is_featured', 'status']; - - public function isFeatured(Payload $payload) - { - return $this->builder->where('is_featured', $payload->asBoolean()); - } - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertTrue($posts->first()->is_featured); - $this->assertEquals('active', $posts->first()->status); - } - - /** - * @test - */ - public function it_can_handle_date_range_filters() - { - Post::factory()->create(['created_at' => now()->subDays(5)]); - Post::factory()->create(['created_at' => now()->subDays(3)]); - Post::factory()->create(['created_at' => now()->subDay()]); - - request()->merge([ - 'created_at' => [ - 'from' => now()->subDays(4)->toDateString(), - 'to' => now()->subDays(2)->toDateString() - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['created_at']; - - public function createdAt(Payload $payload) - { - return $this->builder->whereBetween('created_at', [ - $payload->value['from'], - $payload->value['to'] - ]); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - } - - /** - * @test - */ - public function it_can_handle_json_payload_values() - { - Post::truncate(); - - Post::factory()->create(['tags' => json_encode(['php', 'laravel'])]); - Post::factory()->create(['tags' => json_encode(['vue', 'javascript'])]); - - request()->merge([ - 'tags' => json_encode(['php', 'laravel']) - ]); - - $filter = new class extends Filterable { - protected $filters = ['tags']; - - public function tags(Payload $payload) - { - if ($payload->isJson()) { - return $this->builder->where('tags', 'LIKE', '"' . str_replace('"', '\"', $payload->value) . '"'); - } + $countOfActiveStatus = 7; + $countOfPendingStatus = 5; - return $this->builder; - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - } - - /** - * @test - */ - public function it_returns_all_records_when_no_filters_applied() - { - Post::truncate(); - Post::factory()->count(5)->create(); - - request()->merge([]); - - $filter = new class extends Filterable { - protected $filters = ['status']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(5, $posts); - } - - /** - * @test - */ - public function it_can_use_payload_helper_methods() - { - Post::factory()->create(['title' => 'Laravel Tutorial']); - Post::factory()->create(['title' => 'PHP Guide']); - - request()->merge([ - 'title' => 'Laravel' - ]); - - $filter = new class extends Filterable { - protected $filters = ['title']; - - public function title(Payload $payload) - { - // Using payload helper method - return $this->builder->where('title', 'like', $payload->asLike('both')); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertStringContainsString('Laravel', $posts->first()->title); - } - - /** - * @test - */ - public function it_can_handle_numeric_string_filters() - { - Post::truncate(); - Post::factory()->create(['views' => 100]); - Post::factory()->create(['views' => 200]); - Post::factory()->create(['views' => 50]); - - request()->merge([ - 'views' => '100' - ]); - - $filter = new class extends Filterable { - protected $filters = ['views']; - - public function views(Payload $payload) - { - return $this->builder->where('views', $payload->asInt()); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals(100, $posts->first()->views); - } - - /** - * @test - */ - public function it_can_filter_with_array_values() - { - Post::truncate(); - - Post::factory()->create(['status' => 'active']); - Post::factory()->create(['status' => 'pending']); - Post::factory()->create(['status' => 'stopped']); - - request()->merge([ - 'statuses' => ['active', 'pending'] - ]); - - $filter = new class extends Filterable { - protected $filters = ['statuses']; - - public function statuses(Payload $payload) - { - if ($payload->isArray()) { - return $this->builder->whereIn('status', $payload->value); - } + Post::factory($countOfActiveStatus)->create([ + 'status' => 'active', + 'title' => 'Active posts', + ]); + + Post::factory($countOfPendingStatus)->create([ + 'status' => 'pending', + 'title' => 'Pending posts', + ]); + } + + /** + * It can filter with basic class filter. + * + * @test + */ + public function it_can_test_method_mapping_filter() + { + request()->merge([ + 'status' => 'pending', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status']; + protected $mentors = [ + 'status' => 'filterBystatus', + ]; + + public function filterBystatus(Payload $payload) + { + return $this->builder->where('status', $payload); + } + }; + + $posts = Post::filter($filter)->count(); + + $this->assertEquals(5, $posts); + } + + /** + * It can filter with basic class filter. + * + * @test + */ + public function it_filter_with_ignored_null_or_empty_values() + { + request()->merge([ + 'status' => '', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status']; + protected $mentors = [ + 'status' => 'filterBystatus', + ]; + + public function filterBystatus(Payload $payload) + { + if ($payload->value) { + return $this->builder->where('status', $payload); + } + + return $this->builder->where($payload->field, 'pending'); + } + }; + + $posts = Post::filter($filter)->count(); + + $this->assertEquals(5, $posts); + } + + /** + * It can filter with field and operator. + * + * @test + */ + public function it_can_filter_with_field_and_operator() + { + request()->merge([ + 'status' => [ + 'operator' => 'eq', + 'value' => 'pending', + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->count(); + + $this->assertEquals(5, $posts); + } + + /** + * @test + */ + public function it_can_filter_with_multiple_filters() + { + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel Tutorial', + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP Guide', + ]); + + request()->merge([ + 'status' => 'active', + 'title' => 'Laravel Tutorial', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals('Laravel Tutorial', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_filter_with_multiple_filters_and_operators() + { + // Clear setUp data for this specific test + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Test Post', + 'views' => 100, + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'Another Post', + 'views' => 50, + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Third Post', + 'views' => 150, + ]); + + request()->merge([ + 'status' => [ + 'operator' => 'eq', + 'value' => 'active', + ], + 'views' => [ + 'operator' => 'gt', + 'value' => 75, + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status', 'views']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->operator, $payload->value); + } + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => $post->status === 'active' && $post->views > 75)); + } + + /** + * @test + */ + public function it_can_filter_with_like_operator() + { + Post::truncate(); + + Post::factory()->create(['title' => 'Laravel Framework']); + Post::factory()->create(['title' => 'PHP Tutorial']); + Post::factory()->create(['title' => 'Laravel Tips']); + + request()->merge([ + 'title' => [ + 'operator' => 'like', + 'value' => '%Laravel%', + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['title']; + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => str_contains($post->title, 'Laravel'))); + } + + /** + * @test + */ + public function it_can_filter_with_in_operator() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'status' => [ + 'operator' => 'in', + 'value' => ['active', 'stopped'], + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload) + { + return $this->builder->whereIn('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => in_array($post->status, ['active', 'stopped']))); + } + + /** + * @test + */ + public function it_can_filter_with_between_operator() + { + Post::truncate(); + + Post::factory()->create(['views' => 10]); + Post::factory()->create(['views' => 50]); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 150]); + + request()->merge([ + 'views' => [ + 'operator' => 'between', + 'value' => [40, 110], + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->whereBetween('views', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => $post->views >= 40 && $post->views <= 110)); + } + + /** + * @test + */ + public function it_can_filter_with_null_operator() + { + Post::truncate(); + + Post::factory()->create(['description' => null]); + Post::factory()->create(['description' => 'Some description']); + Post::factory()->create(['description' => null]); + + request()->merge([ + 'description' => [ + 'operator' => 'null', + 'value' => true, + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['description']; + + public function description(Payload $payload) + { + if ($payload->value) { + return $this->builder->whereNull('description'); + } + + return $this->builder->whereNotNull('description'); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => is_null($post->description))); + } + + /** + * @test + */ + public function it_can_handle_camel_case_filter_methods() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active', 'is_featured' => true]); + Post::factory()->create(['status' => 'active', 'is_featured' => false]); + + request()->merge([ + 'is_featured' => true, + ]); + + $filter = new class() extends Filterable { + protected $filters = ['is_featured']; + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertTrue($posts->first()->is_featured); + } + + /** + * @test + */ + public function it_can_use_method_mentors_mapping() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + + request()->merge([ + 'post_status' => 'active', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['post_status']; + protected $mentors = [ + 'post_status' => 'filterByStatus', + ]; + + public function filterByStatus(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + } + + /** + * @test + */ + public function it_can_handle_complex_nested_requests() + { + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel', + 'views' => 100, + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP', + 'views' => 50, + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Vue.js', + 'views' => 150, + ]); + + request()->merge([ + 'status' => [ + 'operator' => '=', + 'value' => 'active', + ], + 'views' => [ + 'operator' => '>=', + 'value' => 100, + ], + 'title' => [ + 'operator' => 'like', + 'value' => '%a%', + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status', 'views', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->operator, $payload->value); + } - return $this->builder; - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(2, $posts); - } - - /** - * @test - */ - public function it_properly_handles_payload_operators() - { - Post::truncate(); - - Post::factory()->create(['views' => 50]); - Post::factory()->create(['views' => 100]); - Post::factory()->create(['views' => 150]); - - $operators = [ - ['operator' => 'gt', 'value' => 75, 'expected' => 2], - ['operator' => 'lt', 'value' => 125, 'expected' => 2], - ['operator' => 'gte', 'value' => 100, 'expected' => 2], - ['operator' => 'lte', 'value' => 100, 'expected' => 2], - ['operator' => 'eq', 'value' => 100, 'expected' => 1], - ['operator' => 'neq', 'value' => 100, 'expected' => 2], - ]; - - foreach ($operators as $test) { - request()->merge([ - 'views' => [ - 'operator' => $test['operator'], - 'value' => $test['value'] - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['views']; - - public function views(Payload $payload) - { - return $this->builder->where('views', $payload->operator, $payload->value); + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_apply_sanitization_to_filters() + { + Post::truncate(); + + Post::factory()->create(['title' => 'Laravel']); + + request()->merge([ + 'title' => ' Laravel ', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['title']; + protected $sanitizers = [ + 'title' => 'trim', + ]; + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_ignores_empty_values_when_configured() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + + request()->merge([ + 'status' => '', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status', 'title']; + protected $ignoreEmptyValues = true; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + // Should not filter by status since it's empty + $posts = Post::filter($filter)->get(); + + // All posts should be returned since status is ignored + $this->assertGreaterThanOrEqual(2, $posts->count()); + } + + /** + * @test + */ + public function it_can_chain_multiple_where_conditions_in_single_filter() + { + Post::factory()->create(['status' => 'active', 'views' => 100]); + Post::factory()->create(['status' => 'active', 'views' => 50]); + Post::factory()->create(['status' => 'pending', 'views' => 100]); + + request()->merge([ + 'combined' => [ + 'status' => 'active', + 'views' => 100, + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['combined']; + + public function combined(Payload $payload) + { + $data = $payload->value; + + return $this->builder + ->where('status', $data['status']) + ->where('views', $data['views']); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals(100, $posts->first()->views); + } + + /** + * @test + */ + public function it_can_use_or_where_conditions() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'status_or' => ['active', 'pending'], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['status_or']; + + public function statusOr(Payload $payload) + { + return $this->builder->where(function ($query) use ($payload) { + foreach ($payload->value as $status) { + $query->orWhere('status', $status); + } + }); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn ($post) => in_array($post->status, ['active', 'pending']))); + } + + /** + * @test + */ + public function it_can_handle_boolean_filters() + { + Post::truncate(); + + Post::factory()->create(['is_featured' => true, 'status' => 'active']); + Post::factory()->create(['is_featured' => false, 'status' => 'active']); + Post::factory()->create(['is_featured' => true, 'status' => 'pending']); + + request()->merge([ + 'is_featured' => true, + 'status' => 'active', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['is_featured', 'status']; + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->asBoolean()); + } + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertTrue($posts->first()->is_featured); + $this->assertEquals('active', $posts->first()->status); + } + + /** + * @test + */ + public function it_can_handle_date_range_filters() + { + Post::factory()->create(['created_at' => now()->subDays(5)]); + Post::factory()->create(['created_at' => now()->subDays(3)]); + Post::factory()->create(['created_at' => now()->subDay()]); + + request()->merge([ + 'created_at' => [ + 'from' => now()->subDays(4)->toDateString(), + 'to' => now()->subDays(2)->toDateString(), + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['created_at']; + + public function createdAt(Payload $payload) + { + return $this->builder->whereBetween('created_at', [ + $payload->value['from'], + $payload->value['to'], + ]); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + } + + /** + * @test + */ + public function it_can_handle_json_payload_values() + { + Post::truncate(); + + Post::factory()->create(['tags' => json_encode(['php', 'laravel'])]); + Post::factory()->create(['tags' => json_encode(['vue', 'javascript'])]); + + request()->merge([ + 'tags' => json_encode(['php', 'laravel']), + ]); + + $filter = new class() extends Filterable { + protected $filters = ['tags']; + + public function tags(Payload $payload) + { + if ($payload->isJson()) { + return $this->builder->where('tags', 'LIKE', '"'.str_replace('"', '\"', $payload->value).'"'); + } + + return $this->builder; + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + } + + /** + * @test + */ + public function it_returns_all_records_when_no_filters_applied() + { + Post::truncate(); + Post::factory()->count(5)->create(); + + request()->merge([]); + + $filter = new class() extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(5, $posts); + } + + /** + * @test + */ + public function it_can_use_payload_helper_methods() + { + Post::factory()->create(['title' => 'Laravel Tutorial']); + Post::factory()->create(['title' => 'PHP Guide']); + + request()->merge([ + 'title' => 'Laravel', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['title']; + + public function title(Payload $payload) + { + // Using payload helper method + return $this->builder->where('title', 'like', $payload->asLike('both')); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertStringContainsString('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_handle_numeric_string_filters() + { + Post::truncate(); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 200]); + Post::factory()->create(['views' => 50]); + + request()->merge([ + 'views' => '100', + ]); + + $filter = new class() extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->asInt()); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals(100, $posts->first()->views); + } + + /** + * @test + */ + public function it_can_filter_with_array_values() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'statuses' => ['active', 'pending'], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['statuses']; + + public function statuses(Payload $payload) + { + if ($payload->isArray()) { + return $this->builder->whereIn('status', $payload->value); + } + + return $this->builder; + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + } + + /** + * @test + */ + public function it_properly_handles_payload_operators() + { + Post::truncate(); + + Post::factory()->create(['views' => 50]); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 150]); + + $operators = [ + ['operator' => 'gt', 'value' => 75, 'expected' => 2], + ['operator' => 'lt', 'value' => 125, 'expected' => 2], + ['operator' => 'gte', 'value' => 100, 'expected' => 2], + ['operator' => 'lte', 'value' => 100, 'expected' => 2], + ['operator' => 'eq', 'value' => 100, 'expected' => 1], + ['operator' => 'neq', 'value' => 100, 'expected' => 2], + ]; + + foreach ($operators as $test) { + request()->merge([ + 'views' => [ + 'operator' => $test['operator'], + 'value' => $test['value'], + ], + ]); + + $filter = new class() extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + }; + + $count = Post::filter($filter)->count(); + + $this->assertEquals( + $test['expected'], + $count, + "Failed for operator {$test['operator']} with value {$test['value']}" + ); } - }; + } - $count = Post::filter($filter)->count(); + /** + * @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 + */ + public function it_can_combine_multiple_filter_patterns() + { + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel Framework', + 'views' => 100, + 'is_featured' => true, + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP Tutorial', + 'views' => 50, + 'is_featured' => false, + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Vue.js Guide', + 'views' => 150, + 'is_featured' => true, + ]); + + request()->merge([ + 'status' => 'active', + 'is_featured' => true, + 'views' => [ + 'operator' => '>=', + 'value' => 100, + ], + 'title' => [ + 'operator' => 'like', + 'value' => '%Framework%', + ], + ]); - $this->assertEquals( - $test['expected'], - $count, - "Failed for operator {$test['operator']} with value {$test['value']}" - ); + $filter = new class() extends Filterable { + protected $filters = ['status', 'is_featured', 'views', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->asBoolean()); + } + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel Framework', $posts->first()->title); + $this->assertTrue($posts->first()->is_featured); + $this->assertEquals('active', $posts->first()->status); + $this->assertGreaterThanOrEqual(100, $posts->first()->views); } - } - - /** - * @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 - */ - public function it_can_combine_multiple_filter_patterns() - { - Post::truncate(); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Laravel Framework', - 'views' => 100, - 'is_featured' => true - ]); - - Post::factory()->create([ - 'status' => 'pending', - 'title' => 'PHP Tutorial', - 'views' => 50, - 'is_featured' => false - ]); - - Post::factory()->create([ - 'status' => 'active', - 'title' => 'Vue.js Guide', - 'views' => 150, - 'is_featured' => true - ]); - - request()->merge([ - 'status' => 'active', - 'is_featured' => true, - 'views' => [ - 'operator' => '>=', - 'value' => 100 - ], - 'title' => [ - 'operator' => 'like', - 'value' => '%Framework%' - ] - ]); - - $filter = new class extends Filterable { - protected $filters = ['status', 'is_featured', 'views', 'title']; - - public function status(Payload $payload) - { - return $this->builder->where('status', $payload->value); - } - - public function isFeatured(Payload $payload) - { - return $this->builder->where('is_featured', $payload->asBoolean()); - } - - public function views(Payload $payload) - { - return $this->builder->where('views', $payload->operator, $payload->value); - } - - public function title(Payload $payload) - { - return $this->builder->where('title', $payload->operator, $payload->value); - } - }; - - $posts = Post::filter($filter)->get(); - - $this->assertCount(1, $posts); - $this->assertEquals('Laravel Framework', $posts->first()->title); - $this->assertTrue($posts->first()->is_featured); - $this->assertEquals('active', $posts->first()->status); - $this->assertGreaterThanOrEqual(100, $posts->first()->views); - } } diff --git a/tests/Unit/Engines/RulesetEngineTest.php b/tests/Unit/Engines/RulesetEngineTest.php index 74f296f..52cf42e 100644 --- a/tests/Unit/Engines/RulesetEngineTest.php +++ b/tests/Unit/Engines/RulesetEngineTest.php @@ -3,202 +3,210 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; use Illuminate\Http\Request; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Engines\Ruleset; -use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Ruleset; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; use Symfony\Component\HttpFoundation\InputBag; class RulesetEngineTest extends TestCase { - public function setUp(): void - { - parent::setUp(); - - $total = 15; - - Post::factory($total)->create([ - 'status' => 'stopped', - 'title' => 'PHP', - 'content' => 'PHP artical' - ]); - - Post::factory($total)->create([ - 'status' => 'active', - 'title' => 'C#', - 'content' => 'C# artical' - ]); - - Post::factory($total)->create([ - 'status' => 'pending', - 'title' => 'Java', - 'content' => 'Java artical' - ]); - - config()->set('filterable.default_engine', 'ruleset'); - } - - /** - * It applies basic ruleset filters correctly. - * @test - */ - public function it_applies_basic_ruleset_filters_correctly() - { - $request = Request::create('/posts?status=pending'); - - $filter = Filterable::withRequest($request)->setAllowedFields(['status'])->useEngine(Ruleset::class)->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It throw exception when enable engine strict mode with not allowed fields. - * @test - */ - public function it_throw_exception_when_enable_engine_strict_mode_globally_when_has_not_allowed_fields() - { - config()->set('filterable.engines.ruleset.strict', true); - - $request = Request::create('/posts?status=pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields([]) - ->useEngine(Ruleset::class) - ->apply(Post::query()); - }, NotAllowedFieldException::class); - } - - /** - * It throw exception when enable engine strict mode with not allowed fields. - * @test - */ - public function it_throw_exception_when_enable_engine_strict_mode_locally_when_has_not_allowed_fields() - { - // Disable strict mode globally - config()->set('filterable.engines.ruleset.strict', false); - - $request = Request::create('/posts?status=pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->strict() - ->setAllowedFields([]) - ->useEngine(Ruleset::class) - ->apply(Post::query()); - }, NotAllowedFieldException::class); - } - - /** - * It can permissive mode locally. - * @test - */ - public function it_can_use_permissive_mode_locally() - { - config()->set('filterable.engines.ruleset.strict', true); - - $request = Request::create('/posts?status=pending'); - - $filterable = Filterable::withRequest($request) - ->permissive() - ->setAllowedFields([]) - ->useEngine(Ruleset::class) - ->apply(Post::query()); - - $this->assertEquals(45, $filterable->count()); - } - - /** - * It applies basic ruleset filters correctly. - * @test - */ - public function it_cant_filtering_with_not_allowed_operators() - { - $request = Request::create('/posts?status=like:pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine(Ruleset::class) - ->apply(Post::query()); - }, InvalidOperatorException::class); - } - - /** - * It can use default operator when invalid receved operator - * @test - */ - public function it_can_use_default_operator_when_invalid_receved_operator() - { - config()->set('filterable.engines.ruleset.strict', false); - $request = Request::create('/posts?status=like:pending'); - - $filterable = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine(Ruleset::class) - ->apply(Post::query()); - - $this->assertEquals(15, $filterable->count()); - } - - /** - * It cant use default operator when enabled strict mode. - * @test - */ - public function it_cant_use_default_operator_when_enabled_strict_mode() - { - config()->set('filterable.engines.ruleset.strict', false); - $request = Request::create('/posts?status=like:pending'); - - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->allowedOperators(['eq']) - ->useEngine(Ruleset::class) - ->strict() - ->apply(Post::query()); - }, InvalidOperatorException::class); - } - - /** - * It can sent json data to filtering operate. - * @test - */ - public function it_can_sent_json_data_to_filtering_operate() - { - config()->set('filterable.engines.ruleset.strict', false); - - $request = Request::create('/posts'); - - $request->setJson(new InputBag([ - 'status' => 'pending' - ])); - - $filter = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->useEngine(Ruleset::class) - ->strict() - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - public function test_it_sanitize_value_before_applying_to_query() - { - $request = Request::create('/posts?status=eq:PENDING'); - - $filter = Filterable::withRequest($request) - ->setAllowedFields(['status']) - ->useEngine(Ruleset::class) - ->setSanitizers([ - 'status' => fn($value) => strtolower($value) - ]) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } + public function setUp(): void + { + parent::setUp(); + + $total = 15; + + Post::factory($total)->create([ + 'status' => 'stopped', + 'title' => 'PHP', + 'content' => 'PHP artical', + ]); + + Post::factory($total)->create([ + 'status' => 'active', + 'title' => 'C#', + 'content' => 'C# artical', + ]); + + Post::factory($total)->create([ + 'status' => 'pending', + 'title' => 'Java', + 'content' => 'Java artical', + ]); + + config()->set('filterable.default_engine', 'ruleset'); + } + + /** + * It applies basic ruleset filters correctly. + * + * @test + */ + public function it_applies_basic_ruleset_filters_correctly() + { + $request = Request::create('/posts?status=pending'); + + $filter = Filterable::withRequest($request)->setAllowedFields(['status'])->useEngine(Ruleset::class)->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It throw exception when enable engine strict mode with not allowed fields. + * + * @test + */ + public function it_throw_exception_when_enable_engine_strict_mode_globally_when_has_not_allowed_fields() + { + config()->set('filterable.engines.ruleset.strict', true); + + $request = Request::create('/posts?status=pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields([]) + ->useEngine(Ruleset::class) + ->apply(Post::query()); + }, NotAllowedFieldException::class); + } + + /** + * It throw exception when enable engine strict mode with not allowed fields. + * + * @test + */ + public function it_throw_exception_when_enable_engine_strict_mode_locally_when_has_not_allowed_fields() + { + // Disable strict mode globally + config()->set('filterable.engines.ruleset.strict', false); + + $request = Request::create('/posts?status=pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->strict() + ->setAllowedFields([]) + ->useEngine(Ruleset::class) + ->apply(Post::query()); + }, NotAllowedFieldException::class); + } + + /** + * It can permissive mode locally. + * + * @test + */ + public function it_can_use_permissive_mode_locally() + { + config()->set('filterable.engines.ruleset.strict', true); + + $request = Request::create('/posts?status=pending'); + + $filterable = Filterable::withRequest($request) + ->permissive() + ->setAllowedFields([]) + ->useEngine(Ruleset::class) + ->apply(Post::query()); + + $this->assertEquals(45, $filterable->count()); + } + + /** + * It applies basic ruleset filters correctly. + * + * @test + */ + public function it_cant_filtering_with_not_allowed_operators() + { + $request = Request::create('/posts?status=like:pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine(Ruleset::class) + ->apply(Post::query()); + }, InvalidOperatorException::class); + } + + /** + * It can use default operator when invalid receved operator. + * + * @test + */ + public function it_can_use_default_operator_when_invalid_receved_operator() + { + config()->set('filterable.engines.ruleset.strict', false); + $request = Request::create('/posts?status=like:pending'); + + $filterable = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine(Ruleset::class) + ->apply(Post::query()); + + $this->assertEquals(15, $filterable->count()); + } + + /** + * It cant use default operator when enabled strict mode. + * + * @test + */ + public function it_cant_use_default_operator_when_enabled_strict_mode() + { + config()->set('filterable.engines.ruleset.strict', false); + $request = Request::create('/posts?status=like:pending'); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->allowedOperators(['eq']) + ->useEngine(Ruleset::class) + ->strict() + ->apply(Post::query()); + }, InvalidOperatorException::class); + } + + /** + * It can sent json data to filtering operate. + * + * @test + */ + public function it_can_sent_json_data_to_filtering_operate() + { + config()->set('filterable.engines.ruleset.strict', false); + + $request = Request::create('/posts'); + + $request->setJson(new InputBag([ + 'status' => 'pending', + ])); + + $filter = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->useEngine(Ruleset::class) + ->strict() + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + public function test_it_sanitize_value_before_applying_to_query() + { + $request = Request::create('/posts?status=eq:PENDING'); + + $filter = Filterable::withRequest($request) + ->setAllowedFields(['status']) + ->useEngine(Ruleset::class) + ->setSanitizers([ + 'status' => fn ($value) => strtolower($value), + ]) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } } diff --git a/tests/Unit/Engines/TreeEngineTest.php b/tests/Unit/Engines/TreeEngineTest.php index cf99899..3e3a8d4 100644 --- a/tests/Unit/Engines/TreeEngineTest.php +++ b/tests/Unit/Engines/TreeEngineTest.php @@ -2,361 +2,369 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; -use PHPUnit\Framework\Test; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; -use Kettasoft\Filterable\Filterable; use Illuminate\Support\Facades\Config; +use Kettasoft\Filterable\Engines\Exceptions\InvalidDataFormatException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Kettasoft\Filterable\Engines\Tree; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Tag; +use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\Models\Tag; +use Kettasoft\Filterable\Tests\TestCase; +use PHPUnit\Framework\Test; use Symfony\Component\HttpFoundation\InputBag; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; -use Kettasoft\Filterable\Engines\Exceptions\InvalidDataFormatException; class TreeEngineTest extends TestCase { - use RefreshDatabase; - - protected $request; - - public function setUp(): void - { - parent::setUp(); - - $total = 15; - - Post::factory($total)->create([ - 'status' => 'stopped', - ]); - - Post::factory($total)->create([ - 'status' => 'active', - 'content' => null - ]); - - Post::factory($total)->create([ - 'status' => 'pending', - 'content' => null - ]); - - Tag::factory()->create([ - 'post_id' => 1, - 'name' => 'stopped' - ]); - - config()->set('filterable.default_engine', 'tree'); - - $this->request = Request::capture()->setJson(new InputBag([ - "filter" => [ - "and" => [ - [ - "field" => "status", - "operator" => "eq", - "value" => "stopped" - ], - ['or' => []] - ] - ] - ])); - } - - /** - * @test - */ - public function it_use_tree__engine_with_simple_filtering() - { - $filter = Filterable::create($this->request) - ->setAllowedFields(['*']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * @test - */ - public function it_use_tree_engine_with_force_set_data() - { - $filter = Filterable::create() - ->setData($this->request->json()->all()) - ->setAllowedFields(['*']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * @test - */ - public function it_use_tree_based_engin_with_field_mapping() - { - $filter = Filterable::create($this->request) - ->setFieldsMap(['filter_by_status' => 'status']) - ->setAllowedFields(['*']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_make_filter_with_tree_engine_and_enable_strict_mode_globally() - { - Config::set('filterable.engines.tree.options.strict', true); - - $this->assertThrows(function () { - Filterable::create($this->request) - ->setAllowedFields([]) - ->apply(Post::query()); - }, NotAllowedFieldException::class); - - // Try after define allowed fields. - $filter = Filterable::create($this->request) - ->setAllowedFields(['status']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_make_filter_with_tree_engine_and_enable_strict_mode_locally() - { - - Config::set('filterable.engines.tree.strict', false); - - $this->assertThrows(function () { - Filterable::create($this->request) - ->strict() - ->setAllowedFields([]) - ->apply(Post::query()); - }, NotAllowedFieldException::class); - - // Try after define allowed fields. - $filter = Filterable::create($this->request) - ->strict() - ->setAllowedFields(['status']) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_can_filter_with_allowed_operators_only() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => "eq", "value" => "pending"], - ['or' => []] - ] - ] - ]; - - // Try after define allowed fields. - $filter = Filterable::create()->strict() - ->allowedOperators(['eq']) - ->setAllowedFields(['status']) - ->setData($data) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_cant_filtering_with_not_allowed_operator() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => "like", "value" => "pending"], - ['or' => []] - ] - ] - ]; - - $this->assertThrows(function () use ($data) { - Filterable::create()->strict() - ->allowedOperators(['eq']) - ->setAllowedFields(['*']) - ->useEngine(Tree::class) - ->setData($data) - ->apply(Post::query()); - }, InvalidOperatorException::class); - - // Try after define allowed operator. - $filter = Filterable::create() - ->strict() - ->setAllowedFields(['status']) - ->allowedOperators(['like']) - ->setData($data) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_can_use_default_operator_when_receved_operator_is_not_allowed_with_permissive_option() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => "like", "value" => "pending"], - ['or' => []] - ] - ] - ]; - - $filter = Filterable::create() - ->permissive() - ->setAllowedFields(['*']) - ->allowedOperators(['in']) - ->setData($data) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_can_use_default_operator_when_receved_operator_is_null_with_permissive_option() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => null, "value" => "pending"], - ['or' => []] - ] - ] - ]; - - $filter = Filterable::create() - ->permissive() - ->setAllowedFields(['*']) - ->setData($data) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_throw_error_when_data_is_incorrectly() - { - $data = [ - "filter" => [ - "and" => [ - ["incorrectly" => "status", "value" => "pending"], - ['or' => []] - ] - ] - ]; - - $this->assertThrows(function () use ($data) { - Filterable::create() - ->permissive() - ->setAllowedFields(['*']) - ->setData($data) - ->apply(Post::query()); - }, InvalidDataFormatException::class); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - #[Test] - public function it_filter_with_tree_based_engin_relations_and_allowed_specific_relation_path() - { - $data = [ - "filter" => [ - "and" => [ - // ["field" => "status", "operator" => "eq", "value" => "stopped"], - ["field" => "tags.name", "operator" => "eq", "value" => "stopped"], - ['or' => []] - ] - ] - ]; - - // Config::set('filterable.engines.tree.strict', true); - - // Try after define allowed fields. - $filter = Filterable::create() - ->setRelations(['tags' => ['name']]) - ->setAllowedFields(['status']) - ->useEngine(Tree::class) - ->setData($data) - ->apply(Post::query()); - - $this->assertEquals(1, $filter->count()); - } - - /** - * It filter with tree based engin and enable strict mode option. - * @test - */ - public function it_can_filter_with_or_and_logical_operator() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => "eq", "value" => "stopped"], - ['or' => [ - ["field" => "status", "operator" => "eq", "value" => "active"], - ["field" => "status", "operator" => "eq", "value" => "pending"], - ]] - ] - ] - ]; - - $filter = Filterable::create() - ->setData($data, true) - ->setAllowedFields(['*']) - ->apply(Post::query()); - - $this->assertEquals(45, $filter->count()); - } - - public function test_it_sanitize_value_before_applying_to_query() - { - $data = [ - "filter" => [ - "and" => [ - ["field" => "status", "operator" => "eq", "value" => "STOPPED"], - ['or' => []] - ] - ] - ]; - - $filter = Filterable::create() - ->setData($data, true) - ->setAllowedFields(['status']) - ->useEngine(Tree::class) - ->setSanitizers([ - 'status' => fn($value) => strtolower($value) - ]) - ->apply(Post::query()); - - $this->assertEquals(15, $filter->count()); - } + use RefreshDatabase; + + protected $request; + + public function setUp(): void + { + parent::setUp(); + + $total = 15; + + Post::factory($total)->create([ + 'status' => 'stopped', + ]); + + Post::factory($total)->create([ + 'status' => 'active', + 'content' => null, + ]); + + Post::factory($total)->create([ + 'status' => 'pending', + 'content' => null, + ]); + + Tag::factory()->create([ + 'post_id' => 1, + 'name' => 'stopped', + ]); + + config()->set('filterable.default_engine', 'tree'); + + $this->request = Request::capture()->setJson(new InputBag([ + 'filter' => [ + 'and' => [ + [ + 'field' => 'status', + 'operator' => 'eq', + 'value' => 'stopped', + ], + ['or' => []], + ], + ], + ])); + } + + /** + * @test + */ + public function it_use_tree__engine_with_simple_filtering() + { + $filter = Filterable::create($this->request) + ->setAllowedFields(['*']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * @test + */ + public function it_use_tree_engine_with_force_set_data() + { + $filter = Filterable::create() + ->setData($this->request->json()->all()) + ->setAllowedFields(['*']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * @test + */ + public function it_use_tree_based_engin_with_field_mapping() + { + $filter = Filterable::create($this->request) + ->setFieldsMap(['filter_by_status' => 'status']) + ->setAllowedFields(['*']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_make_filter_with_tree_engine_and_enable_strict_mode_globally() + { + Config::set('filterable.engines.tree.options.strict', true); + + $this->assertThrows(function () { + Filterable::create($this->request) + ->setAllowedFields([]) + ->apply(Post::query()); + }, NotAllowedFieldException::class); + + // Try after define allowed fields. + $filter = Filterable::create($this->request) + ->setAllowedFields(['status']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_make_filter_with_tree_engine_and_enable_strict_mode_locally() + { + Config::set('filterable.engines.tree.strict', false); + + $this->assertThrows(function () { + Filterable::create($this->request) + ->strict() + ->setAllowedFields([]) + ->apply(Post::query()); + }, NotAllowedFieldException::class); + + // Try after define allowed fields. + $filter = Filterable::create($this->request) + ->strict() + ->setAllowedFields(['status']) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_can_filter_with_allowed_operators_only() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => 'eq', 'value' => 'pending'], + ['or' => []], + ], + ], + ]; + + // Try after define allowed fields. + $filter = Filterable::create()->strict() + ->allowedOperators(['eq']) + ->setAllowedFields(['status']) + ->setData($data) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_cant_filtering_with_not_allowed_operator() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => 'like', 'value' => 'pending'], + ['or' => []], + ], + ], + ]; + + $this->assertThrows(function () use ($data) { + Filterable::create()->strict() + ->allowedOperators(['eq']) + ->setAllowedFields(['*']) + ->useEngine(Tree::class) + ->setData($data) + ->apply(Post::query()); + }, InvalidOperatorException::class); + + // Try after define allowed operator. + $filter = Filterable::create() + ->strict() + ->setAllowedFields(['status']) + ->allowedOperators(['like']) + ->setData($data) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_can_use_default_operator_when_receved_operator_is_not_allowed_with_permissive_option() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => 'like', 'value' => 'pending'], + ['or' => []], + ], + ], + ]; + + $filter = Filterable::create() + ->permissive() + ->setAllowedFields(['*']) + ->allowedOperators(['in']) + ->setData($data) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_can_use_default_operator_when_receved_operator_is_null_with_permissive_option() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => null, 'value' => 'pending'], + ['or' => []], + ], + ], + ]; + + $filter = Filterable::create() + ->permissive() + ->setAllowedFields(['*']) + ->setData($data) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_throw_error_when_data_is_incorrectly() + { + $data = [ + 'filter' => [ + 'and' => [ + ['incorrectly' => 'status', 'value' => 'pending'], + ['or' => []], + ], + ], + ]; + + $this->assertThrows(function () use ($data) { + Filterable::create() + ->permissive() + ->setAllowedFields(['*']) + ->setData($data) + ->apply(Post::query()); + }, InvalidDataFormatException::class); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + #[Test] + public function it_filter_with_tree_based_engin_relations_and_allowed_specific_relation_path() + { + $data = [ + 'filter' => [ + 'and' => [ + // ["field" => "status", "operator" => "eq", "value" => "stopped"], + ['field' => 'tags.name', 'operator' => 'eq', 'value' => 'stopped'], + ['or' => []], + ], + ], + ]; + + // Config::set('filterable.engines.tree.strict', true); + + // Try after define allowed fields. + $filter = Filterable::create() + ->setRelations(['tags' => ['name']]) + ->setAllowedFields(['status']) + ->useEngine(Tree::class) + ->setData($data) + ->apply(Post::query()); + + $this->assertEquals(1, $filter->count()); + } + + /** + * It filter with tree based engin and enable strict mode option. + * + * @test + */ + public function it_can_filter_with_or_and_logical_operator() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => 'eq', 'value' => 'stopped'], + ['or' => [ + ['field' => 'status', 'operator' => 'eq', 'value' => 'active'], + ['field' => 'status', 'operator' => 'eq', 'value' => 'pending'], + ]], + ], + ], + ]; + + $filter = Filterable::create() + ->setData($data, true) + ->setAllowedFields(['*']) + ->apply(Post::query()); + + $this->assertEquals(45, $filter->count()); + } + + public function test_it_sanitize_value_before_applying_to_query() + { + $data = [ + 'filter' => [ + 'and' => [ + ['field' => 'status', 'operator' => 'eq', 'value' => 'STOPPED'], + ['or' => []], + ], + ], + ]; + + $filter = Filterable::create() + ->setData($data, true) + ->setAllowedFields(['status']) + ->useEngine(Tree::class) + ->setSanitizers([ + 'status' => fn ($value) => strtolower($value), + ]) + ->apply(Post::query()); + + $this->assertEquals(15, $filter->count()); + } } diff --git a/tests/Unit/Engines/Utilities/ClauseKeyMapperTest.php b/tests/Unit/Engines/Utilities/ClauseKeyMapperTest.php index 6a76ec6..c318996 100644 --- a/tests/Unit/Engines/Utilities/ClauseKeyMapperTest.php +++ b/tests/Unit/Engines/Utilities/ClauseKeyMapperTest.php @@ -2,97 +2,98 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines\Utilities; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Engines\Foundation\Mappers\ClauseKeyMapper; +use Kettasoft\Filterable\Tests\TestCase; class ClauseKeyMapperTest extends TestCase { - /** - * @test - */ - public function it_uses_default_keys_when_none_are_provided() - { - $mapper = new ClauseKeyMapper(); - - $this->assertEquals('field', $mapper->field()); - $this->assertEquals('operator', $mapper->operator()); - $this->assertEquals('value', $mapper->value()); - } - - /** - * @test - */ - public function it_allow_custom_keys() - { - $mapper = new ClauseKeyMapper([ - 'field' => 'f', - 'operator' => 'o', - 'value' => 'v', - ]); - - $this->assertEquals('f', $mapper->field()); - $this->assertEquals('o', $mapper->operator()); - $this->assertEquals('v', $mapper->value()); - } - - /** - * @test - */ - public function it_thorws_exception_when_keys_are_not_unique() - { - $this->expectException(\InvalidArgumentException::class); - - $this->expectExceptionMessage("Custom clause keys must be unique"); - - new ClauseKeyMapper([ - 'field' => 'f', - 'operator' => 'o', - 'value' => 'f' // Conflict with 'field' - ]); - } - /** - * @test - */ - public function it_thorws_exception_when_all_keys_are_the_same() - { - $this->expectException(\InvalidArgumentException::class); - - new ClauseKeyMapper([ - 'field' => 'same', - 'operator' => 'same', - 'value' => 'same' - ]); - } - - /** - * @test - */ - public function it_can_accepts_custom_keys_from_config() - { - config()->set('filterable.clause_keys', [ - 'field' => 'f', - 'operator' => 'o', - 'value' => 'v', - ]); - - $mapper = new ClauseKeyMapper(); - - $this->assertEquals('f', $mapper->field()); - $this->assertEquals('o', $mapper->operator()); - $this->assertEquals('v', $mapper->value()); - } - - /** - * @test - */ - public function it_can_accepts_partial_custom_keys_and_uses_defaults_for_rest() - { - $mapper = new ClauseKeyMapper([ - 'field' => 'f' - ]); - - $this->assertEquals('f', $mapper->field()); - $this->assertEquals('operator', $mapper->operator()); - $this->assertEquals('value', $mapper->value()); - } + /** + * @test + */ + public function it_uses_default_keys_when_none_are_provided() + { + $mapper = new ClauseKeyMapper(); + + $this->assertEquals('field', $mapper->field()); + $this->assertEquals('operator', $mapper->operator()); + $this->assertEquals('value', $mapper->value()); + } + + /** + * @test + */ + public function it_allow_custom_keys() + { + $mapper = new ClauseKeyMapper([ + 'field' => 'f', + 'operator' => 'o', + 'value' => 'v', + ]); + + $this->assertEquals('f', $mapper->field()); + $this->assertEquals('o', $mapper->operator()); + $this->assertEquals('v', $mapper->value()); + } + + /** + * @test + */ + public function it_thorws_exception_when_keys_are_not_unique() + { + $this->expectException(\InvalidArgumentException::class); + + $this->expectExceptionMessage('Custom clause keys must be unique'); + + new ClauseKeyMapper([ + 'field' => 'f', + 'operator' => 'o', + 'value' => 'f', // Conflict with 'field' + ]); + } + + /** + * @test + */ + public function it_thorws_exception_when_all_keys_are_the_same() + { + $this->expectException(\InvalidArgumentException::class); + + new ClauseKeyMapper([ + 'field' => 'same', + 'operator' => 'same', + 'value' => 'same', + ]); + } + + /** + * @test + */ + public function it_can_accepts_custom_keys_from_config() + { + config()->set('filterable.clause_keys', [ + 'field' => 'f', + 'operator' => 'o', + 'value' => 'v', + ]); + + $mapper = new ClauseKeyMapper(); + + $this->assertEquals('f', $mapper->field()); + $this->assertEquals('o', $mapper->operator()); + $this->assertEquals('v', $mapper->value()); + } + + /** + * @test + */ + public function it_can_accepts_partial_custom_keys_and_uses_defaults_for_rest() + { + $mapper = new ClauseKeyMapper([ + 'field' => 'f', + ]); + + $this->assertEquals('f', $mapper->field()); + $this->assertEquals('operator', $mapper->operator()); + $this->assertEquals('value', $mapper->value()); + } } diff --git a/tests/Unit/Facades/FilterableFacadeTest.php b/tests/Unit/Facades/FilterableFacadeTest.php index 499d2d0..1a347f6 100644 --- a/tests/Unit/Facades/FilterableFacadeTest.php +++ b/tests/Unit/Facades/FilterableFacadeTest.php @@ -3,7 +3,6 @@ namespace Kettasoft\Filterable\Tests\Unit\Facades; use Illuminate\Http\Request; -use Illuminate\Support\Collection; use Kettasoft\Filterable\Facades\Filterable; use Kettasoft\Filterable\Foundation\FilterableSettings; use Kettasoft\Filterable\Foundation\Resources; @@ -29,7 +28,7 @@ public function it_can_create_instance_with_custom_request() { $request = Request::create('/custom', 'GET'); $filterable = Filterable::withRequest($request); - + $this->assertInstanceOf(\Kettasoft\Filterable\Filterable::class, $filterable); $this->assertEquals($request, $filterable->getRequest()); } @@ -67,7 +66,7 @@ public function it_can_set_and_get_allowed_fields() { $fields = ['name', 'email', 'age']; $filterable = Filterable::setAllowedFields($fields); - + $this->assertEquals($fields, $filterable->getAllowedFields()); } @@ -83,7 +82,7 @@ public function it_can_set_and_get_data() { $data = ['name' => 'John', 'age' => 25]; $filterable = Filterable::setData($data); - + $this->assertEquals($data, $filterable->getData()); } @@ -92,7 +91,7 @@ public function it_can_set_and_get_model() { $modelClass = '\Kettasoft\Filterable\Tests\Models\Post'; $filterable = Filterable::setModel($modelClass); - + $this->assertEquals($modelClass, $filterable->getModel()); } @@ -102,8 +101,9 @@ public function it_can_apply_conditional_logic() $condition = true; $called = false; - $filterable = Filterable::when($condition, function($filter) use (&$called) { + $filterable = Filterable::when($condition, function ($filter) use (&$called) { $called = true; + return $filter; }); @@ -115,11 +115,11 @@ public function it_can_apply_conditional_logic() public function it_can_add_and_get_sorting() { $filterClass = 'TestFilter'; - $callback = function($query) { return $query; }; - + $callback = function ($query) { return $query; }; + Filterable::addSorting($filterClass, $callback); $sorting = Filterable::getSorting($filterClass); - + $this->assertNotNull($sorting); } @@ -128,7 +128,7 @@ public function it_can_set_and_get_fields_map() { $map = ['display_name' => 'name', 'user_email' => 'email']; $filterable = Filterable::setFieldsMap($map); - + $this->assertEquals($map, $filterable->getFieldsMap()); } @@ -136,9 +136,9 @@ public function it_can_set_and_get_fields_map() public function it_can_convert_query_to_sql() { $builder = \Kettasoft\Filterable\Tests\Models\Post::query(); - + $sql = Filterable::setBuilder($builder)->toSql(); - + $this->assertEquals('select * from "posts"', strtolower($sql)); } @@ -147,7 +147,7 @@ public function it_can_enable_header_driven_mode() { $config = ['header' => 'X-Filter-Engine']; $filterable = Filterable::withHeaderDrivenMode($config); - + $this->assertInstanceOf(\Kettasoft\Filterable\Filterable::class, $filterable); } @@ -157,4 +157,4 @@ public function it_can_disable_sanitizers() $filterable = Filterable::withoutSanitizers(); $this->assertInstanceOf(\Kettasoft\Filterable\Filterable::class, $filterable); } -} \ No newline at end of file +} diff --git a/tests/Unit/Filterable/AutoFilterScopeInjectionTest.php b/tests/Unit/Filterable/AutoFilterScopeInjectionTest.php index 73e77a6..f0f436a 100644 --- a/tests/Unit/Filterable/AutoFilterScopeInjectionTest.php +++ b/tests/Unit/Filterable/AutoFilterScopeInjectionTest.php @@ -2,32 +2,33 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; -use Kettasoft\Filterable\Filterable; -use Illuminate\Database\Eloquent\Model; -use Kettasoft\Filterable\Tests\TestCase; use Illuminate\Contracts\Database\Query\Builder; +use Illuminate\Database\Eloquent\Model; +use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Providers\AutoRegisterFilterableServiceProvider; +use Kettasoft\Filterable\Tests\TestCase; class AutoFilterScopeInjectionTest extends TestCase { - protected function getPackageProviders($app) - { - return [AutoRegisterFilterableServiceProvider::class, ...parent::getPackageProviders($app)]; - } + protected function getPackageProviders($app) + { + return [AutoRegisterFilterableServiceProvider::class, ...parent::getPackageProviders($app)]; + } - /** - * It test filter scope is available without trait. - * @test - */ - public function ittest_filter_scope_is_available_without_trait() - { - $this->assertInstanceOf(Builder::class, $this->model()->filter(Filterable::create())); - } + /** + * It test filter scope is available without trait. + * + * @test + */ + public function ittest_filter_scope_is_available_without_trait() + { + $this->assertInstanceOf(Builder::class, $this->model()->filter(Filterable::create())); + } - protected function model() - { - $model = new class extends Model {}; + protected function model() + { + $model = new class() extends Model {}; - return $model; - } + return $model; + } } diff --git a/tests/Unit/Filterable/CanUseEngineFromFilterableTest.php b/tests/Unit/Filterable/CanUseEngineFromFilterableTest.php index b81251c..8792a50 100644 --- a/tests/Unit/Filterable/CanUseEngineFromFilterableTest.php +++ b/tests/Unit/Filterable/CanUseEngineFromFilterableTest.php @@ -2,53 +2,60 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Engines\Ruleset; use Kettasoft\Filterable\Engines\Expression; use Kettasoft\Filterable\Engines\Invokable; +use Kettasoft\Filterable\Engines\Ruleset; use Kettasoft\Filterable\Engines\Tree; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Tests\TestCase; class CanUseEngineFromFilterableTest extends TestCase { - /** - * It can filterable class use ruleset engine instance - * @test - */ - public function it_can_filterable_class_use_engine_instance() - { - $filterable = Filterable::create()->useEngine('ruleset'); - - $this->assertInstanceOf(Ruleset::class, $filterable->getEngine()); - } - /** - * It can filterable class use invokable engine instance - * @test - */ - public function it_can_filterable_class_use_invokable_engine_instance() - { - $filterable = Filterable::create()->useEngine('invokable'); - - $this->assertInstanceOf(Invokable::class, $filterable->getEngine()); - } - /** - * It can filterable class use tree engine instance - * @test - */ - public function it_can_filterable_class_use_tree_engine_instance() - { - $filterable = Filterable::create()->useEngine('tree'); - - $this->assertInstanceOf(Tree::class, $filterable->getEngine()); - } - /** - * It can filterable class use expression engine instance - * @test - */ - public function it_can_filterable_class_use_expression_engine_instance() - { - $filterable = Filterable::create()->useEngine('expression'); - - $this->assertInstanceOf(Expression::class, $filterable->getEngine()); - } + /** + * It can filterable class use ruleset engine instance. + * + * @test + */ + public function it_can_filterable_class_use_engine_instance() + { + $filterable = Filterable::create()->useEngine('ruleset'); + + $this->assertInstanceOf(Ruleset::class, $filterable->getEngine()); + } + + /** + * It can filterable class use invokable engine instance. + * + * @test + */ + public function it_can_filterable_class_use_invokable_engine_instance() + { + $filterable = Filterable::create()->useEngine('invokable'); + + $this->assertInstanceOf(Invokable::class, $filterable->getEngine()); + } + + /** + * It can filterable class use tree engine instance. + * + * @test + */ + public function it_can_filterable_class_use_tree_engine_instance() + { + $filterable = Filterable::create()->useEngine('tree'); + + $this->assertInstanceOf(Tree::class, $filterable->getEngine()); + } + + /** + * It can filterable class use expression engine instance. + * + * @test + */ + public function it_can_filterable_class_use_expression_engine_instance() + { + $filterable = Filterable::create()->useEngine('expression'); + + $this->assertInstanceOf(Expression::class, $filterable->getEngine()); + } } diff --git a/tests/Unit/Filterable/FilterAliasesTest.php b/tests/Unit/Filterable/FilterAliasesTest.php index 117a6d4..ede08a4 100644 --- a/tests/Unit/Filterable/FilterAliasesTest.php +++ b/tests/Unit/Filterable/FilterAliasesTest.php @@ -2,24 +2,24 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Post; use Illuminate\Contracts\Database\Query\Builder; use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class FilterAliasesTest extends TestCase { - public function setUp(): void - { - parent::setUp(); + public function setUp(): void + { + parent::setUp(); + + config()->set('filterable.aliases', collect([ + 'posts' => PostFilter::class, + ])); + } - config()->set('filterable.aliases', collect([ - 'posts' => PostFilter::class - ])); - } - public function test_it_can_use_alias_filter_name() - { - $this->assertInstanceOf(Builder::class, Post::filter('posts')); - } + public function test_it_can_use_alias_filter_name() + { + $this->assertInstanceOf(Builder::class, Post::filter('posts')); + } } diff --git a/tests/Unit/Filterable/FilterAuthorizationTest.php b/tests/Unit/Filterable/FilterAuthorizationTest.php index 2ddf4f6..7b1642f 100644 --- a/tests/Unit/Filterable/FilterAuthorizationTest.php +++ b/tests/Unit/Filterable/FilterAuthorizationTest.php @@ -2,28 +2,29 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; +use Illuminate\Validation\UnauthorizedException; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Validation\UnauthorizedException; +use Kettasoft\Filterable\Tests\TestCase; class FilterAuthorizationTest extends TestCase { - /** - * It cant filtering without authorization. - * @test - */ - public function it_cant_filtering_without_authorization() - { - $class = new class extends Filterable { - public function authorize(): bool - { - return false; - } - }; + /** + * It cant filtering without authorization. + * + * @test + */ + public function it_cant_filtering_without_authorization() + { + $class = new class() extends Filterable { + public function authorize(): bool + { + return false; + } + }; - $this->assertThrows(function () use ($class) { - Post::filter($class); - }, UnauthorizedException::class); - } + $this->assertThrows(function () use ($class) { + Post::filter($class); + }, UnauthorizedException::class); + } } diff --git a/tests/Unit/Filterable/FilterResolverTest.php b/tests/Unit/Filterable/FilterResolverTest.php index 46fd8a8..958fcca 100644 --- a/tests/Unit/Filterable/FilterResolverTest.php +++ b/tests/Unit/Filterable/FilterResolverTest.php @@ -4,80 +4,79 @@ use Kettasoft\Filterable\Filterable; use Kettasoft\Filterable\Foundation\Invoker; -use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Support\FilterResolver; use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; +use Kettasoft\Filterable\Tests\Models\Post; class FilterResolverTest extends \Kettasoft\Filterable\Tests\TestCase { - public function setUp(): void - { - parent::setUp(); - - config()->set('filterable.aliases', Filterable::aliases([ - 'post' => PostFilter::class, - ])); - } + public function setUp(): void + { + parent::setUp(); - public function test_it_resolve_filterable_instance_with_alias() - { + config()->set('filterable.aliases', Filterable::aliases([ + 'post' => PostFilter::class, + ])); + } - $model = new Post(); - $builder = $model->newQuery(); + public function test_it_resolve_filterable_instance_with_alias() + { + $model = new Post(); + $builder = $model->newQuery(); - $resolver = new FilterResolver($builder, 'post'); - $filterable = $resolver->resolve(); + $resolver = new FilterResolver($builder, 'post'); + $filterable = $resolver->resolve(); - $this->assertInstanceOf(Invoker::class, $filterable); - } + $this->assertInstanceOf(Invoker::class, $filterable); + } - public function test_it_resolve_filterable_instance_with_class_name() - { - $model = new Post(); - $builder = $model->newQuery(); + public function test_it_resolve_filterable_instance_with_class_name() + { + $model = new Post(); + $builder = $model->newQuery(); - $resolver = new FilterResolver($builder, PostFilter::class); - $filterable = $resolver->resolve(); + $resolver = new FilterResolver($builder, PostFilter::class); + $filterable = $resolver->resolve(); - $this->assertInstanceOf(Invoker::class, $filterable); - } + $this->assertInstanceOf(Invoker::class, $filterable); + } - public function test_it_resolve_filterable_instance_with_instance() - { - $model = new Post(); - $builder = $model->newQuery(); + public function test_it_resolve_filterable_instance_with_instance() + { + $model = new Post(); + $builder = $model->newQuery(); - $resolver = new FilterResolver($builder, new PostFilter()); - $filterable = $resolver->resolve(); + $resolver = new FilterResolver($builder, new PostFilter()); + $filterable = $resolver->resolve(); - $this->assertInstanceOf(Invoker::class, $filterable); - } + $this->assertInstanceOf(Invoker::class, $filterable); + } - public function test_it_throws_exception_when_filter_is_not_defined() - { - $this->expectException(\Kettasoft\Filterable\Exceptions\FilterIsNotDefinedException::class); + public function test_it_throws_exception_when_filter_is_not_defined() + { + $this->expectException(\Kettasoft\Filterable\Exceptions\FilterIsNotDefinedException::class); - $model = new Post(); - $builder = $model->newQuery(); + $model = new Post(); + $builder = $model->newQuery(); - $resolver = new FilterResolver($builder, 'non_existing_alias'); - $resolver->resolve(); - } + $resolver = new FilterResolver($builder, 'non_existing_alias'); + $resolver->resolve(); + } - public function test_it_resolve_filterable_instance_from_model_getter() - { - $model = new class extends Post { - public function getFilterable(): ?string - { - return PostFilter::class; - } - }; + public function test_it_resolve_filterable_instance_from_model_getter() + { + $model = new class() extends Post { + public function getFilterable(): ?string + { + return PostFilter::class; + } + }; - $builder = $model->newQuery(); + $builder = $model->newQuery(); - $resolver = new FilterResolver($builder); - $filterable = $resolver->resolve(); + $resolver = new FilterResolver($builder); + $filterable = $resolver->resolve(); - $this->assertInstanceOf(Invoker::class, $filterable); - } + $this->assertInstanceOf(Invoker::class, $filterable); + } } diff --git a/tests/Unit/Filterable/FilterRulesValidationTest.php b/tests/Unit/Filterable/FilterRulesValidationTest.php index 1e3bb22..a1a262d 100644 --- a/tests/Unit/Filterable/FilterRulesValidationTest.php +++ b/tests/Unit/Filterable/FilterRulesValidationTest.php @@ -2,36 +2,35 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; +use Illuminate\Validation\ValidationException; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Validation\ValidationException; +use Kettasoft\Filterable\Tests\TestCase; class FilterRulesValidationTest extends TestCase { - /** - * It validate incomming request before filtering. - * @test - */ - public function it_validate_incomming_reuqest_before_filtering() - { - $class = new class extends Filterable + /** + * It validate incomming request before filtering. + * + * @test + */ + public function it_validate_incomming_reuqest_before_filtering() { - public function rules(): array - { - return [ - 'id' => ['required', 'array'] - ]; - } - }; - - $request = request()->merge([ - 'id' => null - ]); + $class = new class() extends Filterable { + public function rules(): array + { + return [ + 'id' => ['required', 'array'], + ]; + } + }; + $request = request()->merge([ + 'id' => null, + ]); - $this->assertThrows(function () use ($class, $request) { - $result = Post::filter($class->withRequest($request)); - }, ValidationException::class); - } + $this->assertThrows(function () use ($class, $request) { + $result = Post::filter($class->withRequest($request)); + }, ValidationException::class); + } } diff --git a/tests/Unit/Filterable/FilterableHelperTest.php b/tests/Unit/Filterable/FilterableHelperTest.php index 9b8dd98..a633463 100644 --- a/tests/Unit/Filterable/FilterableHelperTest.php +++ b/tests/Unit/Filterable/FilterableHelperTest.php @@ -8,20 +8,21 @@ class FilterableHelperTest extends TestCase { - /** - * It can creates filterable instance. - * @test - */ - public function it_can_creates_filterable_instance() - { - $query = Post::query(); - $input = ['status' => 'pending']; - $filter = filterable(request()); + /** + * It can creates filterable instance. + * + * @test + */ + public function it_can_creates_filterable_instance() + { + $query = Post::query(); + $input = ['status' => 'pending']; + $filter = filterable(request()); - $filter->setData($input)->setBuilder($query); + $filter->setData($input)->setBuilder($query); - $this->assertInstanceOf(Filterable::class, $filter); - $this->assertSame($query, $filter->getBuilder()); - $this->assertSame($input, $filter->getData()); - } + $this->assertInstanceOf(Filterable::class, $filter); + $this->assertSame($query, $filter->getBuilder()); + $this->assertSame($input, $filter->getData()); + } } diff --git a/tests/Unit/Filterable/FilterableHelpersTest.php b/tests/Unit/Filterable/FilterableHelpersTest.php index 6553247..33eee53 100644 --- a/tests/Unit/Filterable/FilterableHelpersTest.php +++ b/tests/Unit/Filterable/FilterableHelpersTest.php @@ -8,16 +8,17 @@ class FilterableHelpersTest extends TestCase { - /** - * It can auto detect filterable fields from model fillable property. - * @test - */ - public function it_can_auto_detect_filterable_fields_from_model_fillable_property() - { - $filter = Filterable::create() - ->setBuilder(Post::query()) - ->autoSetAllowedFieldsFromModel(); + /** + * It can auto detect filterable fields from model fillable property. + * + * @test + */ + public function it_can_auto_detect_filterable_fields_from_model_fillable_property() + { + $filter = Filterable::create() + ->setBuilder(Post::query()) + ->autoSetAllowedFieldsFromModel(); - $this->assertEquals((new Post)->getFillable(), $filter->getAllowedFields()); - } + $this->assertEquals((new Post())->getFillable(), $filter->getAllowedFields()); + } } diff --git a/tests/Unit/Filterable/FilterableInvokerTest.php b/tests/Unit/Filterable/FilterableInvokerTest.php index a2921a1..c8066b7 100644 --- a/tests/Unit/Filterable/FilterableInvokerTest.php +++ b/tests/Unit/Filterable/FilterableInvokerTest.php @@ -2,143 +2,141 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; -use Illuminate\Support\Facades\Queue; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Post; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Queue; use Kettasoft\Filterable\Foundation\Invoker; use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; use Kettasoft\Filterable\Tests\Jobs\TestExecuteFilterJob; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class FilterableInvokerTest extends TestCase { - public function setUp(): void - { - parent::setUp(); - - Post::factory(10)->create([ - 'status' => 'active' - ]); - - Post::factory(10)->create([ - 'status' => 'stopped' - ]); - } - - public function test_it_can_recover_invoker_instance() - { - $result = Post::filter(new PostFilter); - - $this->assertInstanceOf(Invoker::class, $result); - } - - public function test_it_can_invoke_callback_before() - { - - $result = Post::filter(new PostFilter); - - $count = $result->beforeExecute(function ($builder) { - $builder->where('status', 'active'); - })->count(); - - $this->assertEquals(10, $count); - } + public function setUp(): void + { + parent::setUp(); + + Post::factory(10)->create([ + 'status' => 'active', + ]); + + Post::factory(10)->create([ + 'status' => 'stopped', + ]); + } + + public function test_it_can_recover_invoker_instance() + { + $result = Post::filter(new PostFilter()); + + $this->assertInstanceOf(Invoker::class, $result); + } + + public function test_it_can_invoke_callback_before() + { + $result = Post::filter(new PostFilter()); + + $count = $result->beforeExecute(function ($builder) { + $builder->where('status', 'active'); + })->count(); - public function test_it_can_invoke_callback_after() - { + $this->assertEquals(10, $count); + } - /** - * @var Invoker - */ - $result = Post::filter(new PostFilter); + public function test_it_can_invoke_callback_after() + { + /** + * @var Invoker + */ + $result = Post::filter(new PostFilter()); - $result = $result->afterExecute(function (Collection $result) { - return $result->filter(function ($item) { - return $item->id > 10; - }); - })->get(); + $result = $result->afterExecute(function (Collection $result) { + return $result->filter(function ($item) { + return $item->id > 10; + }); + })->get(); - $this->assertEquals(10, $result->count()); - } + $this->assertEquals(10, $result->count()); + } - public function test_it_executes_when_callback_if_condition_is_true() - { - $invoker = $this->createPartialMock(Invoker::class, []); - $invoked = false; + public function test_it_executes_when_callback_if_condition_is_true() + { + $invoker = $this->createPartialMock(Invoker::class, []); + $invoked = false; - $invoker->when(true, function ($instance) use (&$invoked) { - $invoked = true; - $this->assertInstanceOf(Invoker::class, $instance); - }); + $invoker->when(true, function ($instance) use (&$invoked) { + $invoked = true; + $this->assertInstanceOf(Invoker::class, $instance); + }); - $this->assertTrue($invoked, 'Expected the callback to be invoked when condition is true.'); - } + $this->assertTrue($invoked, 'Expected the callback to be invoked when condition is true.'); + } - public function test_it_does_not_execute_when_callback_if_condition_is_false() - { - $invoker = $this->createPartialMock(Invoker::class, []); - $invoked = false; + public function test_it_does_not_execute_when_callback_if_condition_is_false() + { + $invoker = $this->createPartialMock(Invoker::class, []); + $invoked = false; - $invoker->when(false, function () use (&$invoked) { - $invoked = true; - }); + $invoker->when(false, function () use (&$invoked) { + $invoked = true; + }); - $this->assertFalse($invoked, 'Expected the callback NOT to be invoked when condition is false.'); - } + $this->assertFalse($invoked, 'Expected the callback NOT to be invoked when condition is false.'); + } - public function test_it_executes_unless_callback_if_condition_is_false() - { - $invoker = $this->createPartialMock(Invoker::class, []); - $invoked = false; + public function test_it_executes_unless_callback_if_condition_is_false() + { + $invoker = $this->createPartialMock(Invoker::class, []); + $invoked = false; - $invoker->unless(false, function ($instance) use (&$invoked) { - $invoked = true; - $this->assertInstanceOf(Invoker::class, $instance); - }); + $invoker->unless(false, function ($instance) use (&$invoked) { + $invoked = true; + $this->assertInstanceOf(Invoker::class, $instance); + }); - $this->assertTrue($invoked, 'Expected the callback to be invoked when condition is false.'); - } + $this->assertTrue($invoked, 'Expected the callback to be invoked when condition is false.'); + } - public function test_it_does_not_execute_unless_callback_if_condition_is_true() - { - $invoker = $this->createPartialMock(Invoker::class, []); - $invoked = false; + public function test_it_does_not_execute_unless_callback_if_condition_is_true() + { + $invoker = $this->createPartialMock(Invoker::class, []); + $invoked = false; - $invoker->unless(true, function () use (&$invoked) { - $invoked = true; - }); + $invoker->unless(true, function () use (&$invoked) { + $invoked = true; + }); - $this->assertFalse($invoked, 'Expected the callback NOT to be invoked when condition is true.'); - } + $this->assertFalse($invoked, 'Expected the callback NOT to be invoked when condition is true.'); + } - public function test_it_dispatches_job_with_invoker_through_asJob() - { - Queue::fake(); + public function test_it_dispatches_job_with_invoker_through_asJob() + { + Queue::fake(); - $invoker = Post::filter(new PostFilter()); + $invoker = Post::filter(new PostFilter()); - $invoker->asJob(TestExecuteFilterJob::class, ['extra' => 'test']); + $invoker->asJob(TestExecuteFilterJob::class, ['extra' => 'test']); - Queue::assertPushed(TestExecuteFilterJob::class, function ($job) use ($invoker) { - return $job->invoker === $invoker && $job->extra === 'test'; - }); - } + Queue::assertPushed(TestExecuteFilterJob::class, function ($job) use ($invoker) { + return $job->invoker === $invoker && $job->extra === 'test'; + }); + } - public function test_it_invoke_callback_on_error() - { - $this->expectException(\InvalidArgumentException::class); + public function test_it_invoke_callback_on_error() + { + $this->expectException(\InvalidArgumentException::class); - /** - * @var Invoker - */ - $result = Post::filter(new PostFilter); + /** + * @var Invoker + */ + $result = Post::filter(new PostFilter()); - $result->onError(function ($context, $th) { - throw new \InvalidArgumentException(); - }); + $result->onError(function ($context, $th) { + throw new \InvalidArgumentException(); + }); - $result->whereHas('invalid', 'invalid'); + $result->whereHas('invalid', 'invalid'); - $result->get(); - } + $result->get(); + } } diff --git a/tests/Unit/Filterable/FilterableModelAutoBindingTest.php b/tests/Unit/Filterable/FilterableModelAutoBindingTest.php index a4baeb0..4241667 100644 --- a/tests/Unit/Filterable/FilterableModelAutoBindingTest.php +++ b/tests/Unit/Filterable/FilterableModelAutoBindingTest.php @@ -2,33 +2,33 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; +use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Database\Eloquent\Model; +use Kettasoft\Filterable\Exceptions\FilterClassNotResolvedException; +use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Traits\HasFilterable; -use Illuminate\Contracts\Database\Query\Builder; -use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; -use Kettasoft\Filterable\Exceptions\FilterClassNotResolvedException; class FilterableModelAutoBindingTest extends TestCase { - public function test_it_applies_filter_automatically_from_model_property() - { - $model = new class extends Model { - use HasFilterable; - protected $filterable = PostFilter::class; - }; + public function test_it_applies_filter_automatically_from_model_property() + { + $model = new class() extends Model { + use HasFilterable; + protected $filterable = PostFilter::class; + }; - $this->assertInstanceOf(Builder::class, $model->filter()); - } + $this->assertInstanceOf(Builder::class, $model->filter()); + } - public function test_it_throws_exception_if_no_filter_class_and_no_model_property() - { - $model = new class extends Model { - use HasFilterable; - }; + public function test_it_throws_exception_if_no_filter_class_and_no_model_property() + { + $model = new class() extends Model { + use HasFilterable; + }; - $this->expectException(FilterClassNotResolvedException::class); + $this->expectException(FilterClassNotResolvedException::class); - $model->filter(); - } + $model->filter(); + } } diff --git a/tests/Unit/Filterable/FilterableProfileTest.php b/tests/Unit/Filterable/FilterableProfileTest.php index 8acbb4a..c9c2031 100644 --- a/tests/Unit/Filterable/FilterableProfileTest.php +++ b/tests/Unit/Filterable/FilterableProfileTest.php @@ -3,8 +3,8 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Foundation\Contracts\FilterableProfile; +use Kettasoft\Filterable\Tests\TestCase; class FilterableProfileTest extends TestCase { @@ -15,11 +15,12 @@ public function setUp(): void public function test_it_uses_a_filterable_profile_by_instance() { - $profile = new class implements FilterableProfile { + $profile = new class() implements FilterableProfile { public function __invoke(Filterable $context): Filterable { $context->strict(); $context->setAllowedFields(['field1', 'field2']); + return $context; } }; @@ -33,11 +34,12 @@ public function __invoke(Filterable $context): Filterable public function test_it_uses_a_filterable_profile_by_class_name() { - $profileClass = new class implements FilterableProfile { + $profileClass = new class() implements FilterableProfile { public function __invoke(Filterable $context): Filterable { $context->permissive(); $context->setAllowedFields(['fieldA', 'fieldB', 'fieldC']); + return $context; } }; @@ -51,18 +53,18 @@ public function __invoke(Filterable $context): Filterable public function test_it_uses_a_filterable_profile_by_registred_name() { - $profileClass = new class implements FilterableProfile { + $profileClass = new class() implements FilterableProfile { public function __invoke(Filterable $context): Filterable { $context->strict(); $context->setAllowedFields(['name', 'email']); + return $context; } }; config()->set('filterable.profiles.users-global', get_class($profileClass)); - $filter = Filterable::create()->useProfile('users-global'); $this->assertInstanceOf(Filterable::class, $filter); diff --git a/tests/Unit/Filterable/FilterableProvidedDataTest.php b/tests/Unit/Filterable/FilterableProvidedDataTest.php index 5c40389..c0c3a60 100644 --- a/tests/Unit/Filterable/FilterableProvidedDataTest.php +++ b/tests/Unit/Filterable/FilterableProvidedDataTest.php @@ -3,8 +3,8 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class FilterableProvidedDataTest extends TestCase { @@ -15,7 +15,7 @@ public function setUp(): void Post::factory(5)->create(); Filterable::provide([ - "post_id" => 1, + 'post_id' => 1, ]); } @@ -23,12 +23,12 @@ public function test_it_can_filter_by_provided_data(): void { $filterable = Filterable::tap(function (Filterable $f) { $f->setBuilder(Post::query()); - $f->getBuilder()->where("id", $f->provided("post_id")); + $f->getBuilder()->where('id', $f->provided('post_id')); }); $this->assertStringContainsString('where "id" = 1', $filterable->filter()->toRawSql()); $this->assertEquals(1, $filterable->filter()->count()); - $this->assertEquals(1, $filterable->provided("post_id")); + $this->assertEquals(1, $filterable->provided('post_id')); } public function test_it_can_get_all_provided_data(): void @@ -42,9 +42,9 @@ public function test_it_can_access_specific_provided_key(): void { $filterable = Filterable::create(); - $this->assertTrue($filterable->hasProvided("post_id")); - $this->assertFalse($filterable->hasProvided("non_existing_key")); - $this->assertEquals(1, $filterable->provided("post_id")); - $this->assertEquals(null, $filterable->provided("non_existing_key")); + $this->assertTrue($filterable->hasProvided('post_id')); + $this->assertFalse($filterable->hasProvided('non_existing_key')); + $this->assertEquals(1, $filterable->provided('post_id')); + $this->assertEquals(null, $filterable->provided('non_existing_key')); } } diff --git a/tests/Unit/Filterable/FilterableTapFeatureTest.php b/tests/Unit/Filterable/FilterableTapFeatureTest.php index bf822db..6331048 100644 --- a/tests/Unit/Filterable/FilterableTapFeatureTest.php +++ b/tests/Unit/Filterable/FilterableTapFeatureTest.php @@ -1,4 +1,5 @@ setAllowedFields(['name']); }); - + $this->assertInstanceOf(Filterable::class, $filterable); $this->assertEquals(['name'], $filterable->getAllowedFields()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Filterable/FilterableThroughTest.php b/tests/Unit/Filterable/FilterableThroughTest.php index e631784..657d0c3 100644 --- a/tests/Unit/Filterable/FilterableThroughTest.php +++ b/tests/Unit/Filterable/FilterableThroughTest.php @@ -3,37 +3,36 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Tests\TestCase; class FilterableThroughTest extends TestCase { - public function test_it_can_apply_filter_callbacks_with_through() - { - /** - * @var Filterable - */ - $filter = Filterable::create()->setBuilder(Post::query()); - - $results = $filter->through([ - fn($builder) => $builder->where('id', 1) - ]); - - $this->assertNotEmpty($results->apply()->getBindings()); - } - - public function test_it_throws_exception_when_through_callback_is_invalid() - { - $this->expectException(\InvalidArgumentException::class); - - /** - * @var Filterable - */ - $filter = Filterable::create()->setBuilder(Post::query()); - - $filter->through([ - 'invalid args' - ]); - } + public function test_it_can_apply_filter_callbacks_with_through() + { + /** + * @var Filterable + */ + $filter = Filterable::create()->setBuilder(Post::query()); + + $results = $filter->through([ + fn ($builder) => $builder->where('id', 1), + ]); + + $this->assertNotEmpty($results->apply()->getBindings()); + } + + public function test_it_throws_exception_when_through_callback_is_invalid() + { + $this->expectException(\InvalidArgumentException::class); + + /** + * @var Filterable + */ + $filter = Filterable::create()->setBuilder(Post::query()); + + $filter->through([ + 'invalid args', + ]); + } } diff --git a/tests/Unit/Filterable/FilterableToSqlTest.php b/tests/Unit/Filterable/FilterableToSqlTest.php index 728c40d..1ee8670 100644 --- a/tests/Unit/Filterable/FilterableToSqlTest.php +++ b/tests/Unit/Filterable/FilterableToSqlTest.php @@ -9,16 +9,17 @@ class FilterableToSqlTest extends TestCase { - /** - * It can generate sql string. - * @test - */ - public function it_can_generate_sql_string() - { - $request = Request::create('/posts?status=pending'); - $filterable = filterable($request)->setAllowedFields(['status']) - ->useEngine(Ruleset::class); + /** + * It can generate sql string. + * + * @test + */ + public function it_can_generate_sql_string() + { + $request = Request::create('/posts?status=pending'); + $filterable = filterable($request)->setAllowedFields(['status']) + ->useEngine(Ruleset::class); - $this->assertTrue(is_string($filterable->toSql(Post::query()))); - } + $this->assertTrue(is_string($filterable->toSql(Post::query()))); + } } diff --git a/tests/Unit/Filterable/FilterableWhenConditionTest.php b/tests/Unit/Filterable/FilterableWhenConditionTest.php index c4ae625..ef3522b 100644 --- a/tests/Unit/Filterable/FilterableWhenConditionTest.php +++ b/tests/Unit/Filterable/FilterableWhenConditionTest.php @@ -7,91 +7,91 @@ class FilterableWhenConditionTest extends TestCase { - public function test_it_can_use_singel_when() - { - $filter = Filterable::create()->when(true, function (Filterable $filter) { - $filter->setAllowedFields(['test']); - }); + public function test_it_can_use_singel_when() + { + $filter = Filterable::create()->when(true, function (Filterable $filter) { + $filter->setAllowedFields(['test']); + }); - $this->assertNotEmpty($filter->getAllowedFields()); - } + $this->assertNotEmpty($filter->getAllowedFields()); + } - public function test_it_cant_invoke_when_callback_with_false_condition() - { - $filter = Filterable::create()->when(false, function (Filterable $filter) { - $filter->setAllowedFields(['test']); - }); + public function test_it_cant_invoke_when_callback_with_false_condition() + { + $filter = Filterable::create()->when(false, function (Filterable $filter) { + $filter->setAllowedFields(['test']); + }); - $this->assertEmpty($filter->getAllowedFields()); - } + $this->assertEmpty($filter->getAllowedFields()); + } - public function test_it_can_use_nested_when() - { - $filter = Filterable::create()->when(true, function (Filterable $filter) { - $filter->setAllowedFields(['test1']); + public function test_it_can_use_nested_when() + { + $filter = Filterable::create()->when(true, function (Filterable $filter) { + $filter->setAllowedFields(['test1']); - $filter->when(true, function (Filterable $filter) { - $filter->setAllowedFields(['test2', 'test3']); + $filter->when(true, function (Filterable $filter) { + $filter->setAllowedFields(['test2', 'test3']); - $filter->when(true, fn($filter) => $filter->setAllowedFields(['test4'])); + $filter->when(true, fn ($filter) => $filter->setAllowedFields(['test4'])); - // Not working - $filter->when(false, function (Filterable $filter) { - $filter->setAllowedFields(['test2', 'test3']); + // Not working + $filter->when(false, function (Filterable $filter) { + $filter->setAllowedFields(['test2', 'test3']); + }); + }); }); - }); - }); - - $this->assertCount(4, $filter->getAllowedFields()); - } - - public function test_it_can_use_unless() - { - $filter = Filterable::create()->unless(false, function (Filterable $filter) { - $filter->setAllowedFields(['test1']); - })->unless(true, function (Filterable $filter) { - $filter->setAllowedFields(['test2']); - }); - - $this->assertCount(1, $filter->getAllowedFields()); - } - - public function test_it_can_use_mixed_when_unless() - { - $filter = Filterable::create() - ->when(true, function (Filterable $filter) { - $filter->setAllowedFields(['test1']); - }) - ->unless(false, function (Filterable $filter) { - $filter->setAllowedFields(['test2']); - }) - ->when(false, function (Filterable $filter) { - $filter->setAllowedFields(['test3']); - }) - ->unless(true, function (Filterable $filter) { - $filter->setAllowedFields(['test4']); - }); - - $this->assertCount(2, $filter->getAllowedFields()); - } - - public function test_it_can_use_nested_mixed_when_unless() - { - $filter = Filterable::create() - ->when(true, function (Filterable $filter) { - $filter->setAllowedFields(['test1']); - - $filter->unless(false, function (Filterable $filter) { - $filter->setAllowedFields(['test2']); - - $filter->when(true, fn($filter) => $filter->setAllowedFields(['test3'])); - - $filter->unless(true, function (Filterable $filter) { - $filter->setAllowedFields(['test4']); - }); + + $this->assertCount(4, $filter->getAllowedFields()); + } + + public function test_it_can_use_unless() + { + $filter = Filterable::create()->unless(false, function (Filterable $filter) { + $filter->setAllowedFields(['test1']); + })->unless(true, function (Filterable $filter) { + $filter->setAllowedFields(['test2']); }); - }); - $this->assertCount(3, $filter->getAllowedFields()); - } + $this->assertCount(1, $filter->getAllowedFields()); + } + + public function test_it_can_use_mixed_when_unless() + { + $filter = Filterable::create() + ->when(true, function (Filterable $filter) { + $filter->setAllowedFields(['test1']); + }) + ->unless(false, function (Filterable $filter) { + $filter->setAllowedFields(['test2']); + }) + ->when(false, function (Filterable $filter) { + $filter->setAllowedFields(['test3']); + }) + ->unless(true, function (Filterable $filter) { + $filter->setAllowedFields(['test4']); + }); + + $this->assertCount(2, $filter->getAllowedFields()); + } + + public function test_it_can_use_nested_mixed_when_unless() + { + $filter = Filterable::create() + ->when(true, function (Filterable $filter) { + $filter->setAllowedFields(['test1']); + + $filter->unless(false, function (Filterable $filter) { + $filter->setAllowedFields(['test2']); + + $filter->when(true, fn ($filter) => $filter->setAllowedFields(['test3'])); + + $filter->unless(true, function (Filterable $filter) { + $filter->setAllowedFields(['test4']); + }); + }); + }); + + $this->assertCount(3, $filter->getAllowedFields()); + } } diff --git a/tests/Unit/Filterable/FilteringWithHeaderDrivenModeTest.php b/tests/Unit/Filterable/FilteringWithHeaderDrivenModeTest.php index 0b25f23..4d5b5a7 100644 --- a/tests/Unit/Filterable/FilteringWithHeaderDrivenModeTest.php +++ b/tests/Unit/Filterable/FilteringWithHeaderDrivenModeTest.php @@ -2,221 +2,229 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Engines\Tree; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Engines\Ruleset; use Illuminate\Validation\ValidationException; -use Illuminate\Foundation\Testing\RefreshDatabase; use Kettasoft\Filterable\Engines\Factory\EngineManager; +use Kettasoft\Filterable\Engines\Ruleset; +use Kettasoft\Filterable\Engines\Tree; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Tests\TestCase; class FilteringWithHeaderDrivenModeTest extends TestCase { - use RefreshDatabase; - - public function setUp(): void - { - parent::setUp(); - - config()->set('filterable.header_driven_mode', [ - 'header_name' => 'X-Filter-Mode', - 'enabled' => true, - 'allowed_engines' => [], - 'engine_map' => [], - 'fallback_strategy' => 'default', - ]); - } - - /** - * It filtering with enable global header driven mode options. - * @test - */ - public function it_filtering_with_enable_global_header_driven_mode_options() - { - $engine = 'ruleset'; - - $request = Request::capture(); - - $request->headers->set('X-Filter-Mode', $engine); - - $filter = Filterable::withRequest($request); - - $this->assertEquals($engine, $request->header('X-Filter-Mode')); - - $this->assertInstanceOf(Ruleset::class, $filter->getEngine()); - } - - /** - * it can filtering with allowed header driven mode engines only. - * @test - */ - public function it_can_filtering_with_allowed_header_driven_mode_engines_only() - { - $engine = 'tree'; - - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'X-Filter-Mode', - 'allowed_engines' => ['tree'] - ]); - - $request = Request::capture(); - - $request->headers->set('X-Filter-Mode', $engine); - - $filter = Filterable::withRequest($request); - - $this->assertEquals($engine, $request->header('X-Filter-Mode')); - - $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); - } - - /** - * It can't filtering with not allowed header driven mode engines. - * @test - */ - public function it_cant_filtering_with_not_allowed_header_driven_mode_engines() - { - $engine = 'ruleset'; - - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'X-Filter-Mode', - 'allowed_engines' => ['tree'] - ]); + use RefreshDatabase; + + public function setUp(): void + { + parent::setUp(); - $request = Request::capture(); + config()->set('filterable.header_driven_mode', [ + 'header_name' => 'X-Filter-Mode', + 'enabled' => true, + 'allowed_engines' => [], + 'engine_map' => [], + 'fallback_strategy' => 'default', + ]); + } - $request->headers->set('X-Filter-Mode', $engine); + /** + * It filtering with enable global header driven mode options. + * + * @test + */ + public function it_filtering_with_enable_global_header_driven_mode_options() + { + $engine = 'ruleset'; + + $request = Request::capture(); + + $request->headers->set('X-Filter-Mode', $engine); + + $filter = Filterable::withRequest($request); - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request); - }, ValidationException::class); - } + $this->assertEquals($engine, $request->header('X-Filter-Mode')); - /** - * It can filtering with engine map name engine. - * @test - */ - public function it_can_filtering_with_engine_map_name_engine() - { - $engine = 'mobile'; + $this->assertInstanceOf(Ruleset::class, $filter->getEngine()); + } - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'X-Filter-Mode', - 'engine_map' => ['mobile' => 'ruleset'] - ]); + /** + * it can filtering with allowed header driven mode engines only. + * + * @test + */ + public function it_can_filtering_with_allowed_header_driven_mode_engines_only() + { + $engine = 'tree'; + + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'X-Filter-Mode', + 'allowed_engines' => ['tree'], + ]); + + $request = Request::capture(); + + $request->headers->set('X-Filter-Mode', $engine); - $request = Request::capture(); + $filter = Filterable::withRequest($request); - $request->headers->set('X-Filter-Mode', $engine); + $this->assertEquals($engine, $request->header('X-Filter-Mode')); - $filter = Filterable::withRequest($request); + $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); + } - $this->assertEquals($engine, $request->header('X-Filter-Mode')); + /** + * It can't filtering with not allowed header driven mode engines. + * + * @test + */ + public function it_cant_filtering_with_not_allowed_header_driven_mode_engines() + { + $engine = 'ruleset'; + + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'X-Filter-Mode', + 'allowed_engines' => ['tree'], + ]); + + $request = Request::capture(); + + $request->headers->set('X-Filter-Mode', $engine); + + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request); + }, ValidationException::class); + } - $this->assertInstanceOf(Ruleset::class, $filter->getEngine()); - } + /** + * It can filtering with engine map name engine. + * + * @test + */ + public function it_can_filtering_with_engine_map_name_engine() + { + $engine = 'mobile'; + + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'X-Filter-Mode', + 'engine_map' => ['mobile' => 'ruleset'], + ]); + + $request = Request::capture(); + + $request->headers->set('X-Filter-Mode', $engine); + + $filter = Filterable::withRequest($request); + + $this->assertEquals($engine, $request->header('X-Filter-Mode')); + + $this->assertInstanceOf(Ruleset::class, $filter->getEngine()); + } - /** - * It use default engine when engine name not defined. - * @test - */ - public function it_use_default_engine_when_engine_name_not_defined() - { - $engine = 'mobile'; + /** + * It use default engine when engine name not defined. + * + * @test + */ + public function it_use_default_engine_when_engine_name_not_defined() + { + $engine = 'mobile'; - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'X-Filter-Mode', - 'fallback_strategy' => 'default', - ]); + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'X-Filter-Mode', + 'fallback_strategy' => 'default', + ]); - $request = Request::capture(); + $request = Request::capture(); - $request->headers->set('X-Filter-Mode', $engine); + $request->headers->set('X-Filter-Mode', $engine); - $filter = Filterable::withRequest($request); + $filter = Filterable::withRequest($request); - $this->assertEquals($engine, $request->header('X-Filter-Mode')); + $this->assertEquals($engine, $request->header('X-Filter-Mode')); - $this->assertInstanceOf(get_class($filter->getEngine()), EngineManager::generate(config('filterable.default_engine'), $filter)); - } + $this->assertInstanceOf(get_class($filter->getEngine()), EngineManager::generate(config('filterable.default_engine'), $filter)); + } - /** - * it throw exception when engine is not define. - * @test - */ - public function it_throw_exception_when_engine_is_not_define() - { - $engine = 'mobile'; + /** + * it throw exception when engine is not define. + * + * @test + */ + public function it_throw_exception_when_engine_is_not_define() + { + $engine = 'mobile'; - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'X-Filter-Mode', - 'fallback_strategy' => 'error', - ]); + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'X-Filter-Mode', + 'fallback_strategy' => 'error', + ]); - $request = Request::capture(); + $request = Request::capture(); - $request->headers->set('X-Filter-Mode', $engine); + $request->headers->set('X-Filter-Mode', $engine); - $this->assertThrows(function () use ($request) { - Filterable::withRequest($request); - }, ValidationException::class); - } + $this->assertThrows(function () use ($request) { + Filterable::withRequest($request); + }, ValidationException::class); + } - /** - * It use engine with custom header name. - * @test - */ - public function it_use_engine_with_custom_header_name() - { - $engine = 'tree'; + /** + * It use engine with custom header name. + * + * @test + */ + public function it_use_engine_with_custom_header_name() + { + $engine = 'tree'; - config()->set('filterable.header_driven_mode', [ - 'enabled' => true, - 'header_name' => 'mode', - 'fallback_strategy' => 'error', - ]); + config()->set('filterable.header_driven_mode', [ + 'enabled' => true, + 'header_name' => 'mode', + 'fallback_strategy' => 'error', + ]); - $request = Request::capture(); + $request = Request::capture(); - $request->headers->set('mode', $engine); + $request->headers->set('mode', $engine); - $filter = Filterable::withRequest($request); + $filter = Filterable::withRequest($request); - $this->assertEquals($engine, $request->header('mode')); + $this->assertEquals($engine, $request->header('mode')); - $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); - } + $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); + } - /** - * it use header driven mode per request only. - * @test - */ - public function it_use_header_driven_mode_per_request_only() - { - $engine = 'tree'; + /** + * it use header driven mode per request only. + * + * @test + */ + public function it_use_header_driven_mode_per_request_only() + { + $engine = 'tree'; - config()->set('filterable.header_driven_mode', [ - 'header_name' => 'X-Filter-Mode', - 'enabled' => false, - 'allowed_engines' => [], - 'engine_map' => [], - 'fallback_strategy' => 'default', - ]); + config()->set('filterable.header_driven_mode', [ + 'header_name' => 'X-Filter-Mode', + 'enabled' => false, + 'allowed_engines' => [], + 'engine_map' => [], + 'fallback_strategy' => 'default', + ]); - $request = Request::capture(); + $request = Request::capture(); - $request->headers->set('mode', $engine); + $request->headers->set('mode', $engine); - $filter = Filterable::withRequest($request)->withHeaderDrivenMode(); + $filter = Filterable::withRequest($request)->withHeaderDrivenMode(); - $this->assertEquals($engine, $request->header('mode')); + $this->assertEquals($engine, $request->header('mode')); - $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); - } + $this->assertInstanceOf(Tree::class, EngineManager::generate($engine, $filter)); + } } diff --git a/tests/Unit/Filterable/ModelToBuilderResolutionTest.php b/tests/Unit/Filterable/ModelToBuilderResolutionTest.php index 3bfef56..0021a28 100644 --- a/tests/Unit/Filterable/ModelToBuilderResolutionTest.php +++ b/tests/Unit/Filterable/ModelToBuilderResolutionTest.php @@ -2,45 +2,48 @@ namespace Kettasoft\Filterable\Tests\Unit\Filterable; +use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; -use Illuminate\Contracts\Database\Eloquent\Builder; +use Kettasoft\Filterable\Tests\TestCase; class ModelToBuilderResolutionTest extends TestCase { - /** - * It can inject query builder from model string. - * @test - */ - public function it_can_inject_query_builder_from_model_string() - { - $filter = Filterable::create()->setModel(Post::class); - - $this->assertInstanceOf(Builder::class, $filter->apply()); - } - - /** - * Summary of it_can_filtering_with_inject_model_as_instance - * @test - */ - public function it_can_inject_query_builder_from_model_instance() - { - $filter = Filterable::create()->setModel(new Post); - - $this->assertInstanceOf(Builder::class, $filter->apply()); - } - - /** - * Summary of it_can_filtering_with_inject_model_as_by_class - * @test - */ - public function it_can_inject_query_builder_from_model_using_custom_class() - { - $filter = new class extends Filterable { - protected $model = Post::class; - }; - - $this->assertInstanceOf(Builder::class, $filter->apply()); - } + /** + * It can inject query builder from model string. + * + * @test + */ + public function it_can_inject_query_builder_from_model_string() + { + $filter = Filterable::create()->setModel(Post::class); + + $this->assertInstanceOf(Builder::class, $filter->apply()); + } + + /** + * Summary of it_can_filtering_with_inject_model_as_instance. + * + * @test + */ + public function it_can_inject_query_builder_from_model_instance() + { + $filter = Filterable::create()->setModel(new Post()); + + $this->assertInstanceOf(Builder::class, $filter->apply()); + } + + /** + * Summary of it_can_filtering_with_inject_model_as_by_class. + * + * @test + */ + public function it_can_inject_query_builder_from_model_using_custom_class() + { + $filter = new class() extends Filterable { + protected $model = Post::class; + }; + + $this->assertInstanceOf(Builder::class, $filter->apply()); + } } diff --git a/tests/Unit/FilterableCacheTest.php b/tests/Unit/FilterableCacheTest.php index eb55ffa..df5597e 100644 --- a/tests/Unit/FilterableCacheTest.php +++ b/tests/Unit/FilterableCacheTest.php @@ -3,10 +3,10 @@ namespace Kettasoft\Filterable\Tests\Unit; use Illuminate\Support\Facades\Cache; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Foundation\Caching\CacheKeyGenerator; use Kettasoft\Filterable\Foundation\Caching\FilterableCacheManager; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class FilterableCacheTest extends TestCase { @@ -50,11 +50,13 @@ public function it_can_remember_values() $result1 = $manager->remember('test_remember', 60, function () use (&$callCount) { $callCount++; + return 'computed_value'; }); $result2 = $manager->remember('test_remember', 60, function () use (&$callCount) { $callCount++; + return 'computed_value'; }); @@ -187,15 +189,15 @@ public function cache_key_generator_normalizes_class_names() public function it_caches_filterable_results_on_first_execution() { // Create a simple test filter - $filter = new class extends \Kettasoft\Filterable\Filterable { + $filter = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Set up test data $post = Post::create([ - 'title' => 'Test Post', + 'title' => 'Test Post', 'content' => 'Test Body', - 'status' => 'active', + 'status' => 'active', ]); // Enable caching and execute @@ -211,15 +213,15 @@ public function it_caches_filterable_results_on_first_execution() public function it_retrieves_cached_results_on_second_execution() { // Create test filter - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create initial data $post1 = Post::create([ - 'title' => 'First Post', + 'title' => 'First Post', 'content' => 'Body 1', - 'status' => 'active', + 'status' => 'active', ]); // First execution - should cache @@ -232,9 +234,9 @@ public function it_retrieves_cached_results_on_second_execution() // Create more data AFTER caching $post2 = Post::create([ - 'title' => 'Second Post', + 'title' => 'Second Post', 'content' => 'Body 2', - 'status' => 'active', + 'status' => 'active', ]); // Second execution - should return cached results (still 1 post, not 2) @@ -254,14 +256,14 @@ public function it_retrieves_cached_results_on_second_execution() /** @test */ public function it_executes_query_when_caching_is_disabled() { - $filter = new class extends \Kettasoft\Filterable\Filterable { + $filter = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; $post1 = Post::create([ - 'title' => 'Post 1', + 'title' => 'Post 1', 'content' => 'Body 1', - 'status' => 'active', + 'status' => 'active', ]); // Execute without caching @@ -270,9 +272,9 @@ public function it_executes_query_when_caching_is_disabled() // Add another post $post2 = Post::create([ - 'title' => 'Post 2', + 'title' => 'Post 2', 'content' => 'Body 2', - 'status' => 'active', + 'status' => 'active', ]); // Execute again without caching - should see both posts @@ -283,21 +285,21 @@ public function it_executes_query_when_caching_is_disabled() /** @test */ public function it_creates_different_cache_for_different_terminal_methods() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create test data Post::create([ - 'title' => 'Post 1', + 'title' => 'Post 1', 'content' => 'Body 1', - 'status' => 'active', + 'status' => 'active', ]); Post::create([ - 'title' => 'Post 2', + 'title' => 'Post 2', 'content' => 'Body 2', - 'status' => 'active', + 'status' => 'active', ]); // Cache with get() @@ -321,15 +323,15 @@ public function it_creates_different_cache_for_different_terminal_methods() /** @test */ public function it_caches_with_user_scope() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create test data $post = Post::create([ - 'title' => 'Scoped Post', + 'title' => 'Scoped Post', 'content' => 'Body', - 'status' => 'active', + 'status' => 'active', ]); // Cache with user scope @@ -346,9 +348,9 @@ public function it_caches_with_user_scope() // Add more data Post::create([ - 'title' => 'Another Post', + 'title' => 'Another Post', 'content' => 'Body', - 'status' => 'active', + 'status' => 'active', ]); $results2 = $filter2->cache(60) @@ -363,15 +365,15 @@ public function it_caches_with_user_scope() /** @test */ public function it_can_flush_cached_results() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create initial data Post::create([ - 'title' => 'Post 1', + 'title' => 'Post 1', 'content' => 'Body 1', - 'status' => 'active', + 'status' => 'active', ]); // Cache results @@ -384,9 +386,9 @@ public function it_can_flush_cached_results() // Add more data Post::create([ - 'title' => 'Post 2', + 'title' => 'Post 2', 'content' => 'Body 2', - 'status' => 'active', + 'status' => 'active', ]); // Flush cache @@ -404,16 +406,16 @@ public function it_can_flush_cached_results() /** @test */ public function it_caches_paginated_results() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create test data for ($i = 1; $i <= 25; $i++) { Post::create([ - 'title' => "Post $i", + 'title' => "Post $i", 'content' => "Body $i", - 'status' => 'active', + 'status' => 'active', ]); } @@ -429,9 +431,9 @@ public function it_caches_paginated_results() // Add more posts for ($i = 26; $i <= 30; $i++) { Post::create([ - 'title' => "Post $i", + 'title' => "Post $i", 'content' => "Body $i", - 'status' => 'active', + 'status' => 'active', ]); } @@ -448,7 +450,7 @@ public function it_caches_paginated_results() /** @test */ public function it_uses_cache_tags_correctly() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; @@ -459,9 +461,9 @@ public function it_uses_cache_tags_correctly() // Create test data Post::create([ - 'title' => 'Tagged Post', + 'title' => 'Tagged Post', 'content' => 'Body', - 'status' => 'active', + 'status' => 'active', ]); // Cache with tags @@ -475,9 +477,9 @@ public function it_uses_cache_tags_correctly() // Add more data Post::create([ - 'title' => 'Another Post', + 'title' => 'Another Post', 'content' => 'Body', - 'status' => 'active', + 'status' => 'active', ]); // Flush by tags @@ -496,15 +498,15 @@ public function it_uses_cache_tags_correctly() /** @test */ public function it_respects_cache_when_condition() { - $filterClass = new class extends \Kettasoft\Filterable\Filterable { + $filterClass = new class() extends \Kettasoft\Filterable\Filterable { protected $filters = []; }; // Create test data Post::create([ - 'title' => 'Post 1', + 'title' => 'Post 1', 'content' => 'Body 1', - 'status' => 'active', + 'status' => 'active', ]); // Cache only when condition is true @@ -517,9 +519,9 @@ public function it_respects_cache_when_condition() // Add more data Post::create([ - 'title' => 'Post 2', + 'title' => 'Post 2', 'content' => 'Body 2', - 'status' => 'active', + 'status' => 'active', ]); // Don't cache when condition is false diff --git a/tests/Unit/FilterableEventSystemTest.php b/tests/Unit/FilterableEventSystemTest.php index d4d2198..bc5d005 100644 --- a/tests/Unit/FilterableEventSystemTest.php +++ b/tests/Unit/FilterableEventSystemTest.php @@ -2,16 +2,16 @@ namespace Tests\Unit; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Facades\Filterable; use Illuminate\Foundation\Testing\RefreshDatabase; +use Kettasoft\Filterable\Facades\Filterable; use Kettasoft\Filterable\Filterable as BaseFilterable; use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; /** - * Test suite for the Filterable Event System - * + * Test suite for the Filterable Event System. + * * These tests demonstrate how to test the event system functionality. */ class FilterableEventSystemTest extends TestCase @@ -24,7 +24,7 @@ public function setUp(): void } /** - * Clean up listeners after each test + * Clean up listeners after each test. */ protected function tearDown(): void { @@ -212,8 +212,8 @@ public function it_handles_listener_exceptions_gracefully() /** @test */ public function it_can_flush_all_listeners() { - Filterable::on('filterable.applied', fn($filterable) => null); - Filterable::observe(PostFilter::class, fn() => null); + Filterable::on('filterable.applied', fn ($filterable) => null); + Filterable::observe(PostFilter::class, fn () => null); $this->assertCount(1, Filterable::getListeners('filterable.applied')); $this->assertCount(1, Filterable::getObservers(PostFilter::class)); @@ -280,8 +280,8 @@ public function it_respects_global_events_disabled_configuration() /** @test */ public function it_can_retrieve_registered_listeners() { - $callback1 = fn($filterable) => null; - $callback2 = fn($filterable) => null; + $callback1 = fn ($filterable) => null; + $callback2 = fn ($filterable) => null; Filterable::on('filterable.applied', $callback1); Filterable::on('filterable.applied', $callback2); @@ -296,8 +296,8 @@ public function it_can_retrieve_registered_listeners() /** @test */ public function it_can_retrieve_registered_observers() { - $callback1 = fn() => null; - $callback2 = fn() => null; + $callback1 = fn () => null; + $callback2 = fn () => null; Filterable::observe(PostFilter::class, $callback1); Filterable::observe(PostFilter::class, $callback2); @@ -330,7 +330,6 @@ public function it_fires_events_in_correct_order() $events[] = 'finished'; }); - PostFilter::create()->apply(Post::query())->get(); $this->assertEquals( diff --git a/tests/Unit/FilterableHasRelationsTest.php b/tests/Unit/FilterableHasRelationsTest.php index a1959bb..a0191b4 100644 --- a/tests/Unit/FilterableHasRelationsTest.php +++ b/tests/Unit/FilterableHasRelationsTest.php @@ -3,9 +3,9 @@ namespace Kettasoft\Filterable\Tests\Unit; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Tag; use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\Models\Tag; +use Kettasoft\Filterable\Tests\TestCase; class FilterableHasRelationsTest extends TestCase { @@ -16,6 +16,7 @@ public function setUp(): void Tag::factory(5)->create(['post_id' => 1]); Tag::factory(5)->create(['post_id' => 1, 'name' => 'archived']); } + public function test_it_can_filter_using_has_relation() { // HasMany relation @@ -30,7 +31,7 @@ public function test_it_can_filter_using_has_relation() $filterable->useEngine('ruleset'); $filterable->setAllowedFields([ - 'name' + 'name', ]); })->apply(); diff --git a/tests/Unit/Foundation/SorterTest.php b/tests/Unit/Foundation/SorterTest.php index 13426be..6836bb3 100644 --- a/tests/Unit/Foundation/SorterTest.php +++ b/tests/Unit/Foundation/SorterTest.php @@ -3,377 +3,376 @@ namespace Kettasoft\Filterable\Tests\Unit\Foundation; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Foundation\Contracts\Sortable; use Kettasoft\Filterable\Tests\Http\Filters\PostFilter; +use Kettasoft\Filterable\Tests\Models\Post; +use Kettasoft\Filterable\Tests\TestCase; class SorterTest extends TestCase { - public function test_it_registers_sorting_rules_for_a_filterable_class() - { - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->default('id', 'asc') - ->alias('recent', ['created_at' => 'desc']); - }); - - $sortable = Filterable::getSorting(PostFilter::class); + public function test_it_registers_sorting_rules_for_a_filterable_class() + { + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->default('id', 'asc') + ->alias('recent', ['created_at' => 'desc']); + }); - $this->assertNotNull($sortable); - $this->assertEquals(['id', 'title'], $sortable->getAllowed()); - $this->assertEquals(['id', 'asc'], $sortable->getDefault()); - $this->assertEquals(['recent' => ['created_at' => 'desc']], $sortable->getAliases()); - } + $sortable = Filterable::getSorting(PostFilter::class); - public function test_it_applies_default_sorting() - { - Filterable::addSorting([PostFilter::class], function (Sortable $sort) { - return $sort->allow(['id', 'title', 'created_at']) - ->default('created_at', 'desc'); - }); + $this->assertNotNull($sortable); + $this->assertEquals(['id', 'title'], $sortable->getAllowed()); + $this->assertEquals(['id', 'asc'], $sortable->getDefault()); + $this->assertEquals(['recent' => ['created_at' => 'desc']], $sortable->getAliases()); + } - $query = Post::filter(new PostFilter()); + public function test_it_applies_default_sorting() + { + Filterable::addSorting([PostFilter::class], function (Sortable $sort) { + return $sort->allow(['id', 'title', 'created_at']) + ->default('created_at', 'desc'); + }); - $this->assertStringContainsString('order by "created_at" desc', $query->toSql()); - } + $query = Post::filter(new PostFilter()); - public function test_it_applies_requested_sorting() - { - request()->merge(['sort' => 'title,-id']); + $this->assertStringContainsString('order by "created_at" desc', $query->toSql()); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['title', 'id']); - }); + public function test_it_applies_requested_sorting() + { + request()->merge(['sort' => 'title,-id']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['title', 'id']); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_applies_alias_sorting_correctly() - { - request()->merge(['sort' => 'id,title,recent']); + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title', 'created_at']) - ->alias('recent', [['created_at', 'desc']]); - }); + public function test_it_applies_alias_sorting_correctly() + { + request()->merge(['sort' => 'id,title,recent']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title', 'created_at']) + ->alias('recent', [['created_at', 'desc']]); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "created_at" desc, "id" asc, "title" asc', $sql); - } + $sql = $query->toSql(); - public function test_it_ignores_invalid_sorting_fields() - { - request()->merge(['sort' => 'id,invalid_field,-another_invalid']); + $this->assertStringContainsString('order by "created_at" desc, "id" asc, "title" asc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']); - }); + public function test_it_ignores_invalid_sorting_fields() + { + request()->merge(['sort' => 'id,invalid_field,-another_invalid']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "id" asc', $sql); - $this->assertStringNotContainsString('invalid_field', $sql); - $this->assertStringNotContainsString('another_invalid', $sql); - } + $sql = $query->toSql(); - public function test_it_allows_all_fields_for_sorting() - { - request()->merge(['sort' => 'id,any_field,-another_field']); + $this->assertStringContainsString('order by "id" asc', $sql); + $this->assertStringNotContainsString('invalid_field', $sql); + $this->assertStringNotContainsString('another_invalid', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['*']); - }); + public function test_it_allows_all_fields_for_sorting() + { + request()->merge(['sort' => 'id,any_field,-another_field']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['*']); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "id" asc, "any_field" asc, "another_field" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_falls_back_to_default_when_no_sorting_provided() - { - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->default('title', 'desc'); - }); + $this->assertStringContainsString('order by "id" asc, "any_field" asc, "another_field" desc', $sql); + } - $query = Post::filter(new PostFilter()); + public function test_it_falls_back_to_default_when_no_sorting_provided() + { + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->default('title', 'desc'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_falls_back_to_empty_when_no_sorting_provided_and_no_default_set() - { - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']); - }); + $this->assertStringContainsString('order by "title" desc', $sql); + } - $query = Post::filter(new PostFilter()); + public function test_it_falls_back_to_empty_when_no_sorting_provided_and_no_default_set() + { + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringNotContainsString('order by', $sql); - } + $sql = $query->toSql(); - public function test_it_falls_back_to_default_when_only_invalid_sorting_provided() - { - request()->merge(['sort' => 'invalid_field,-another_invalid']); + $this->assertStringNotContainsString('order by', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->default('id', 'asc'); - }); + public function test_it_falls_back_to_default_when_only_invalid_sorting_provided() + { + request()->merge(['sort' => 'invalid_field,-another_invalid']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->default('id', 'asc'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "id" asc', $sql); - $this->assertStringNotContainsString('invalid_field', $sql); - $this->assertStringNotContainsString('another_invalid', $sql); - } + $sql = $query->toSql(); - public function test_it_handles_no_allowed_fields() - { - request()->merge(['sort' => 'id,title']); + $this->assertStringContainsString('order by "id" asc', $sql); + $this->assertStringNotContainsString('invalid_field', $sql); + $this->assertStringNotContainsString('another_invalid', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow([]); - }); + public function test_it_handles_no_allowed_fields() + { + request()->merge(['sort' => 'id,title']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow([]); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringNotContainsString('order by', $sql); - } + $sql = $query->toSql(); - public function test_it_handles_no_sorting_registered() - { - request()->merge(['sort' => 'id,title']); + $this->assertStringNotContainsString('order by', $sql); + } - $query = Post::filter(new PostFilter()); + public function test_it_handles_no_sorting_registered() + { + request()->merge(['sort' => 'id,title']); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringNotContainsString('order by', $sql); - } + $sql = $query->toSql(); - public function test_it_handles_empty_sorting_input() - { - request()->merge(['sort' => '']); + $this->assertStringNotContainsString('order by', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->default('id', 'asc'); - }); + public function test_it_handles_empty_sorting_input() + { + request()->merge(['sort' => '']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->default('id', 'asc'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "id" asc', $sql); - } + $sql = $query->toSql(); - public function test_it_handles_no_sorting_input() - { - // No 'sort' parameter in request + $this->assertStringContainsString('order by "id" asc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->default('id', 'asc'); - }); + public function test_it_handles_no_sorting_input() + { + // No 'sort' parameter in request - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->default('id', 'asc'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "id" asc', $sql); - } + $sql = $query->toSql(); - public function test_it_can_register_local_sorting() - { - request()->merge(['sort' => 'custom_sort']); + $this->assertStringContainsString('order by "id" asc', $sql); + } - $filter = Filterable::create()->sorting(function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->alias('custom_sort', [['title', 'asc'], ['id', 'desc']]); - }); + public function test_it_can_register_local_sorting() + { + request()->merge(['sort' => 'custom_sort']); - $query = Post::filter($filter); + $filter = Filterable::create()->sorting(function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->alias('custom_sort', [['title', 'asc'], ['id', 'desc']]); + }); - $sql = $query->toSql(); + $query = Post::filter($filter); - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_prefers_local_over_global_sorting() - { - request()->merge(['sort' => 'custom_sort']); + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->alias('custom_sort', [['title', 'desc'], ['id', 'asc']]); - }); + public function test_it_prefers_local_over_global_sorting() + { + request()->merge(['sort' => 'custom_sort']); - $filter = PostFilter::create()->sorting(function (Sortable $sort) { - return $sort->allow(['id', 'title']) - ->alias('custom_sort', [['title', 'asc'], ['id', 'desc']]); - }); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->alias('custom_sort', [['title', 'desc'], ['id', 'asc']]); + }); - $query = Post::filter($filter); + $filter = PostFilter::create()->sorting(function (Sortable $sort) { + return $sort->allow(['id', 'title']) + ->alias('custom_sort', [['title', 'asc'], ['id', 'desc']]); + }); - $sql = $query->toSql(); + $query = Post::filter($filter); - // Should use local sorting (title asc, id desc) - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - $this->assertStringNotContainsString('order by "title" desc, "id" asc', $sql); - } + $sql = $query->toSql(); - public function test_it_throws_exception_for_invalid_sorting_callback() - { - $this->expectException(\InvalidArgumentException::class); - Filterable::addSorting(PostFilter::class, 'non_existent_function'); - } + // Should use local sorting (title asc, id desc) + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + $this->assertStringNotContainsString('order by "title" desc, "id" asc', $sql); + } - public function test_it_can_sorting_with_invokable_class() - { - request()->merge(['sort' => 'title,-id']); + public function test_it_throws_exception_for_invalid_sorting_callback() + { + $this->expectException(\InvalidArgumentException::class); + Filterable::addSorting(PostFilter::class, 'non_existent_function'); + } - $class = new class implements \Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable { - public function __invoke(Sortable $sort): Sortable - { - return $sort->allow(['title', 'id']); - } - }; + public function test_it_can_sorting_with_invokable_class() + { + request()->merge(['sort' => 'title,-id']); - Filterable::addSorting(PostFilter::class, $class); + $class = new class() implements \Kettasoft\Filterable\Foundation\Contracts\Sorting\Invokable { + public function __invoke(Sortable $sort): Sortable + { + return $sort->allow(['title', 'id']); + } + }; - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, $class); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_can_map_fields_for_sorting() - { - request()->merge(['sort' => 'name,-date']); + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['name', 'date']) - ->map(['name' => 'title', 'date' => 'created_at']); - }); + public function test_it_can_map_fields_for_sorting() + { + request()->merge(['sort' => 'name,-date']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['name', 'date']) + ->map(['name' => 'title', 'date' => 'created_at']); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" asc, "created_at" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_can_set_custom_sort_key() - { - request()->merge(['s' => 'title,-id']); + $this->assertStringContainsString('order by "title" asc, "created_at" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['title', 'id']) - ->setSortKey('s'); - }); + public function test_it_can_set_custom_sort_key() + { + request()->merge(['s' => 'title,-id']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['title', 'id']) + ->setSortKey('s'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_throws_exception_for_empty_sort_key() - { - $this->expectException(\InvalidArgumentException::class); + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->setSortKey(''); - }); - } + public function test_it_throws_exception_for_empty_sort_key() + { + $this->expectException(\InvalidArgumentException::class); - public function test_it_can_set_custom_delimiter() - { - request()->merge(['sort' => 'title|-id']); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->setSortKey(''); + }); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['title', 'id']) - ->setDelimiter('|'); - }); + public function test_it_can_set_custom_delimiter() + { + request()->merge(['sort' => 'title|-id']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['title', 'id']) + ->setDelimiter('|'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); - } + $sql = $query->toSql(); - public function test_it_throws_exception_for_empty_delimiter() - { - $this->expectException(\InvalidArgumentException::class); + $this->assertStringContainsString('order by "title" asc, "id" desc', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->setDelimiter(''); - }); - } + public function test_it_throws_exception_for_empty_delimiter() + { + $this->expectException(\InvalidArgumentException::class); - public function test_it_can_set_nulls_position_first() - { - request()->merge(['sort' => 'title,-id']); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->setDelimiter(''); + }); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['title', 'id']) - ->setNullsPosition('first'); - }); + public function test_it_can_set_nulls_position_first() + { + request()->merge(['sort' => 'title,-id']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['title', 'id']) + ->setNullsPosition('first'); + }); - $sql = $query->toSql(); + $query = Post::filter(new PostFilter()); - $this->assertStringContainsString('order by title asc NULLS FIRST, id desc NULLS FIRST', $sql); - } + $sql = $query->toSql(); - public function test_it_can_set_nulls_position_last() - { - request()->merge(['sort' => 'title,-id']); + $this->assertStringContainsString('order by title asc NULLS FIRST, id desc NULLS FIRST', $sql); + } - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->allow(['title', 'id']) - ->setNullsPosition('last'); - }); + public function test_it_can_set_nulls_position_last() + { + request()->merge(['sort' => 'title,-id']); - $query = Post::filter(new PostFilter()); + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->allow(['title', 'id']) + ->setNullsPosition('last'); + }); + $query = Post::filter(new PostFilter()); - $sql = $query->toSql(); + $sql = $query->toSql(); - $this->assertStringContainsString('order by title asc NULLS LAST, id desc NULLS LAST', $sql); - } + $this->assertStringContainsString('order by title asc NULLS LAST, id desc NULLS LAST', $sql); + } - public function test_it_throws_exception_for_invalid_nulls_position() - { - $this->expectException(\InvalidArgumentException::class); + public function test_it_throws_exception_for_invalid_nulls_position() + { + $this->expectException(\InvalidArgumentException::class); - Filterable::addSorting(PostFilter::class, function (Sortable $sort) { - return $sort->setNullsPosition('invalid_position'); - }); - } + Filterable::addSorting(PostFilter::class, function (Sortable $sort) { + return $sort->setNullsPosition('invalid_position'); + }); + } } diff --git a/tests/Unit/Providers/FilterableServiceProviderTest.php b/tests/Unit/Providers/FilterableServiceProviderTest.php index 2be0e5d..ed5624c 100644 --- a/tests/Unit/Providers/FilterableServiceProviderTest.php +++ b/tests/Unit/Providers/FilterableServiceProviderTest.php @@ -3,21 +3,19 @@ namespace Tests\Unit\Providers; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Artisan; use InvalidArgumentException; use Kettasoft\Filterable\Filterable; -use Illuminate\Foundation\Application; -use Illuminate\Support\Facades\Artisan; -use Kettasoft\Filterable\Tests\TestCase; -use Kettasoft\Filterable\Commands\MakeFilterCommand; -use Kettasoft\Filterable\Providers\FilterableServiceProvider; use Kettasoft\Filterable\Foundation\Events\FilterableEventManager; -use Kettasoft\Filterable\Foundation\Profiler\Storage\FileProfilerStorage; -use Kettasoft\Filterable\Foundation\Profiler\Storage\DatabaseProfilerStorage; use Kettasoft\Filterable\Foundation\Profiler\Contracts\ProfilerStorageContract; +use Kettasoft\Filterable\Foundation\Profiler\Storage\DatabaseProfilerStorage; +use Kettasoft\Filterable\Foundation\Profiler\Storage\FileProfilerStorage; +use Kettasoft\Filterable\Providers\FilterableServiceProvider; +use Kettasoft\Filterable\Tests\TestCase; /** * Test suite for FilterableServiceProvider. - * + * * Tests the service provider's registration, configuration merging, * binding resolution, and extension hooks. */ @@ -158,7 +156,7 @@ public function filterable_receives_request_instance(): void /** @test */ public function extension_hooks_are_called_when_available(): void { - $provider = new class ($this->app) extends FilterableServiceProvider { + $provider = new class($this->app) extends FilterableServiceProvider { public bool $customEnginesRegistered = false; public bool $customSanitizersRegistered = false; public bool $additionalServicesRegistered = false; diff --git a/tests/Unit/Sanitization/SanitizeUnitTest.php b/tests/Unit/Sanitization/SanitizeUnitTest.php index 7c858be..942362c 100644 --- a/tests/Unit/Sanitization/SanitizeUnitTest.php +++ b/tests/Unit/Sanitization/SanitizeUnitTest.php @@ -7,65 +7,69 @@ class SanitizeUnitTest extends TestCase { - /** - * It can sanitize value using resolver as string. - * @test - */ - public function it_can_sanitize_value_using_resolver_as_string() - { - $value = ' value'; - $resolver = TrimSanitizer::class; + /** + * It can sanitize value using resolver as string. + * + * @test + */ + public function it_can_sanitize_value_using_resolver_as_string() + { + $value = ' value'; + $resolver = TrimSanitizer::class; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = HandlerFactory::handle($value, $resolver); - $this->assertEquals(trim($value), $afterSanitize); - } + $this->assertEquals(trim($value), $afterSanitize); + } - /** - * It can sanitize value using resolver as function. - * @test - */ - public function it_can_sanitize_value_using_resolver_as_function() - { - $value = ' value'; - $resolver = function ($value) { - return trim($value); - }; + /** + * It can sanitize value using resolver as function. + * + * @test + */ + public function it_can_sanitize_value_using_resolver_as_function() + { + $value = ' value'; + $resolver = function ($value) { + return trim($value); + }; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = HandlerFactory::handle($value, $resolver); - $this->assertEquals(trim($value), $afterSanitize); - } + $this->assertEquals(trim($value), $afterSanitize); + } - /** - * It can sanitize value using resolver as instance. - * @test - */ - public function it_can_sanitize_value_using_resolver_as_instance() - { - $value = ' value'; - $resolver = new TrimSanitizer; + /** + * It can sanitize value using resolver as instance. + * + * @test + */ + public function it_can_sanitize_value_using_resolver_as_instance() + { + $value = ' value'; + $resolver = new TrimSanitizer(); - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = HandlerFactory::handle($value, $resolver); - $this->assertEquals(trim($value), $afterSanitize); - } + $this->assertEquals(trim($value), $afterSanitize); + } - /** - * It can sanitize value using resolver as instance. - * @test - */ - public function it_can_sanitize_value_using_resolver_as_array() - { - $value = ' value'; + /** + * It can sanitize value using resolver as instance. + * + * @test + */ + public function it_can_sanitize_value_using_resolver_as_array() + { + $value = ' value'; - $resolvers = [ - fn($value) => strtoupper($value), - new TrimSanitizer - ]; + $resolvers = [ + fn ($value) => strtoupper($value), + new TrimSanitizer(), + ]; - $afterSanitize = HandlerFactory::handle($value, $resolvers); + $afterSanitize = HandlerFactory::handle($value, $resolvers); - $this->assertEquals(strtoupper(trim($value)), $afterSanitize); - } + $this->assertEquals(strtoupper(trim($value)), $afterSanitize); + } } diff --git a/tests/Unit/Sanitization/SanitizerCountTest.php b/tests/Unit/Sanitization/SanitizerCountTest.php index 287e182..5bca165 100644 --- a/tests/Unit/Sanitization/SanitizerCountTest.php +++ b/tests/Unit/Sanitization/SanitizerCountTest.php @@ -3,20 +3,21 @@ namespace Kettasoft\Filterable\Tests\Unit\Sanitization; use Countable; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Sanitization\Sanitizer; +use Kettasoft\Filterable\Tests\TestCase; class SanitizerCountTest extends TestCase { - /** - * It can get number of sanitizers via count keywork. - * @test - */ - public function it_can_get_number_of_sanitizers_via_count_keywork() - { - $sanitizer = new Sanitizer([function () {}]); + /** + * It can get number of sanitizers via count keywork. + * + * @test + */ + public function it_can_get_number_of_sanitizers_via_count_keywork() + { + $sanitizer = new Sanitizer([function () {}]); - $this->assertInstanceOf(Countable::class, $sanitizer); - $this->assertEquals(1, count($sanitizer)); - } + $this->assertInstanceOf(Countable::class, $sanitizer); + $this->assertEquals(1, count($sanitizer)); + } } diff --git a/tests/Unit/Sanitization/TrimSanitizer.php b/tests/Unit/Sanitization/TrimSanitizer.php index 05418c4..95eb8dc 100644 --- a/tests/Unit/Sanitization/TrimSanitizer.php +++ b/tests/Unit/Sanitization/TrimSanitizer.php @@ -6,8 +6,8 @@ class TrimSanitizer implements Sanitizable { - public function sanitize($value): mixed - { - return trim($value); - } + public function sanitize($value): mixed + { + return trim($value); + } } diff --git a/tests/Unit/Support/PayloadTest.php b/tests/Unit/Support/PayloadTest.php index 567e6c6..39f3f04 100644 --- a/tests/Unit/Support/PayloadTest.php +++ b/tests/Unit/Support/PayloadTest.php @@ -2,529 +2,529 @@ namespace Kettasoft\Filterable\Tests\Unit\Support; -use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Tests\TestCase; class PayloadTest extends TestCase { - protected Payload $payload; - - public function setUp(): void - { - parent::setUp(); - - $this->payload = new Payload( - field: 'name', - operator: '=', - value: 'Filterable', - rawValue: ' Filterable ' - ); - } - - public function test_it_can_be_instantiated() - { - $this->assertInstanceOf(Payload::class, $this->payload); - $this->assertEquals('name', $this->payload->field); - $this->assertEquals('=', $this->payload->operator); - $this->assertEquals('Filterable', $this->payload->value); - $this->assertEquals(' Filterable ', $this->payload->rawValue); - } - - public function test_static_create_method() - { - $payload = Payload::create('age', '>', 25, '25'); - $this->assertInstanceOf(Payload::class, $payload); - $this->assertEquals(25, $payload->value); - } - - public function test_length_method() - { - $this->assertEquals(10, $this->payload->length()); - - $arrayPayload = new Payload('tags', 'IN', ['php', 'laravel'], ['php', 'laravel']); - $this->assertEquals(2, $arrayPayload->length()); - } - - public function test_empty_and_not_empty() - { - $emptyPayload = new Payload('name', '=', '', ''); - $this->assertTrue($emptyPayload->isEmpty()); - $this->assertFalse($emptyPayload->isNotEmpty()); - - $this->assertTrue($this->payload->isNotEmpty()); - } - - public function test_null_check() - { - $nullPayload = new Payload('name', '=', null, null); - $this->assertTrue($nullPayload->isNull()); - } - - public function test_boolean_checks() - { - $truePayload = new Payload('active', '=', true, true); - $falsePayload = new Payload('active', '=', 'false', 'false'); - - $this->assertTrue($truePayload->isBoolean()); - $this->assertTrue($falsePayload->isBoolean()); - - $this->assertTrue($truePayload->isTrue()); - $this->assertTrue($falsePayload->isFalse()); - - $this->assertTrue($truePayload->asBoolean()); - $this->assertFalse($falsePayload->asBoolean()); - } - - public function test_json_checks() - { - $jsonPayload = new Payload('data', '=', '{"name":"John"}', '{"name":"John"}'); - $this->assertTrue($jsonPayload->isJson()); - $this->assertIsArray($jsonPayload->asArray()); - $this->assertEquals(['name' => 'John'], $jsonPayload->asArray()); - - $strictInvalid = new Payload('data', '=', '"string"', '"string"'); - $this->assertFalse($strictInvalid->isJson(true)); - $this->assertTrue($strictInvalid->isJson(false)); - } - - public function test_numeric_checks() - { - $numericPayload = new Payload('age', '=', '42', '42'); - $this->assertTrue($numericPayload->isNumeric()); - $this->assertEquals(42, $numericPayload->asInt()); - } - - public function test_string_and_array_checks() - { - $this->assertTrue($this->payload->isString()); - - $arrayPayload = new Payload('ids', 'IN', [1, 2, 3], [1, 2, 3]); - $this->assertTrue($arrayPayload->isArray()); - } - - public function test_wrap_and_like_helpers() - { - $this->assertEquals('%Filterable%', $this->payload->asLike()); - $this->assertEquals('%Filterable', $this->payload->asLike('start')); - $this->assertEquals('Filterable%', $this->payload->asLike('end')); - } - - public function test_to_array_and_json() - { - $array = $this->payload->toArray(); - $this->assertArrayHasKey('field', $array); - $this->assertArrayHasKey('operator', $array); - - $json = $this->payload->toJson(); - $this->assertJson($json); - } - - public function test_to_string_returns_value() - { - $this->assertEquals('Filterable', (string) $this->payload); - } - - public function test_raw_method_returns_before_sanitize_value() - { - $this->assertEquals(' Filterable ', $this->payload->raw()); - } - - public function test_is_empty_string() - { - $emptyString = new Payload('field', '=', ' ', ' '); - $this->assertTrue($emptyString->isEmptyString()); - - $this->assertFalse($this->payload->isEmptyString()); - } - - public function test_is_not_null_or_empty() - { - $this->assertTrue($this->payload->isNotNullOrEmpty()); - - $nullPayload = new Payload('field', '=', null, null); - $this->assertFalse($nullPayload->isNotNullOrEmpty()); - - $emptyPayload = new Payload('field', '=', '', ''); - $this->assertFalse($emptyPayload->isNotNullOrEmpty()); - } - - public function test_is_date() - { - $datePayload = new Payload('field', '=', '2024-01-15', '2024-01-15'); - $this->assertTrue($datePayload->isDate()); - - $nonDatePayload = new Payload('field', '=', 'not a date', 'not a date'); - $this->assertFalse($nonDatePayload->isDate()); - - $numberPayload = new Payload('field', '=', 123, 123); - $this->assertFalse($numberPayload->isDate()); - } - - public function test_is_timestamp() - { - $timestampPayload = new Payload('field', '=', 1705324800, 1705324800); - $this->assertTrue($timestampPayload->isTimestamp()); - - $timestampStringPayload = new Payload('field', '=', '1705324800', '1705324800'); - $this->assertTrue($timestampStringPayload->isTimestamp()); - - $nonTimestampPayload = new Payload('field', '=', 'not timestamp', 'not timestamp'); - $this->assertFalse($nonTimestampPayload->isTimestamp()); - } - - public function test_as_carbon() - { - $datePayload = new Payload('field', '=', '2024-01-15', '2024-01-15'); - $carbon = $datePayload->asCarbon(); - $this->assertInstanceOf(\Carbon\Carbon::class, $carbon); - - $timestampPayload = new Payload('field', '=', 1705324800, 1705324800); - $carbonFromTimestamp = $timestampPayload->asCarbon(); - $this->assertInstanceOf(\Carbon\Carbon::class, $carbonFromTimestamp); - - $nonDatePayload = new Payload('field', '=', 'invalid', 'invalid'); - $this->assertNull($nonDatePayload->asCarbon()); - } - - public function test_regex() - { - $emailPayload = new Payload('field', '=', 'john@example.com', 'john@example.com'); - $emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; - - $this->assertTrue($emailPayload->regex($emailPattern)); - - $nonEmailPayload = new Payload('field', '=', 'not-an-email', 'not-an-email'); - $this->assertFalse($nonEmailPayload->regex($emailPattern)); - - $numberPayload = new Payload('field', '=', 123, 123); - $this->assertFalse($numberPayload->regex($emailPattern)); - } - - public function test_as_slug() - { - $payload = new Payload('field', '=', 'Hello World Test', 'Hello World Test'); - - $this->assertEquals('hello-world-test', $payload->asSlug()); - $this->assertEquals('hello_world_test', $payload->asSlug('_')); - } - - public function test_in_method() - { - $payload = new Payload('field', '=', 'apple', 'apple'); - - $this->assertTrue($payload->in('apple', 'banana', 'orange')); - $this->assertTrue($payload->in(['apple', 'banana', 'orange'])); - $this->assertFalse($payload->in('banana', 'orange', 'grape')); - } - - public function test_not_in_method() - { - $payload = new Payload('field', '=', 'apple', 'apple'); - - $this->assertFalse($payload->notIn('apple', 'banana', 'orange')); - $this->assertTrue($payload->notIn('banana', 'orange', 'grape')); - } - - public function test_is_method_with_multiple_checks() - { - $jsonPayload = new Payload('field', '=', '["a","b"]', '["a","b"]'); - $this->assertTrue($jsonPayload->is('json', 'notEmpty')); - - $emptyPayload = new Payload('field', '=', '', ''); - $this->assertFalse($emptyPayload->is('json', 'notEmpty')); - } - - public function test_is_method_with_negation() - { - $stringPayload = new Payload('field', '=', 'value', 'value'); - - $this->assertTrue($stringPayload->is('!empty', 'string')); - $this->assertTrue($stringPayload->is('!null', '!numeric')); - } - - public function test_is_method_throws_exception_for_invalid_method() - { - $this->expectException(\InvalidArgumentException::class); - - $payload = new Payload('field', '=', 'value', 'value'); - $payload->is('invalidCheck'); - } - - public function test_is_any_method() - { - $numericPayload = new Payload('field', '=', '123', '123'); - - $this->assertTrue($numericPayload->isAny('numeric', 'boolean')); - $this->assertTrue($numericPayload->isAny('array', 'numeric')); - $this->assertFalse($numericPayload->isAny('array', 'boolean')); - } - - public function test_is_any_method_with_negation() - { - $stringPayload = new Payload('field', '=', 'value', 'value'); - - $this->assertTrue($stringPayload->isAny('!numeric', 'array')); - $this->assertTrue($stringPayload->isAny('!null', '!empty')); - } - - public function test_is_any_method_throws_exception_for_invalid_method() - { - $this->expectException(\InvalidArgumentException::class); - - $payload = new Payload('field', '=', 'value', 'value'); - $payload->isAny('invalidCheck'); - } - - public function test_set_value() - { - $this->payload->setValue('NewValue'); - $this->assertEquals('NewValue', $this->payload->value); - } - - public function test_set_field() - { - $this->payload->setField('newField'); - $this->assertEquals('newField', $this->payload->field); - } - - public function test_set_operator() - { - $this->payload->setOperator('!='); - $this->assertEquals('!=', $this->payload->operator); - } - - public function test_get_field() - { - $this->assertEquals('name', $this->payload->getField()); - } - - public function test_get_operator() - { - $this->assertEquals('=', $this->payload->getOperator()); - } - - public function test_explode_method() - { - $payload = new Payload('field', '=', 'apple,banana,orange', 'apple,banana,orange'); - - $this->assertEquals(['apple', 'banana', 'orange'], $payload->explode(',')); - $this->assertEquals(['apple,banana,orange'], $payload->explode('|')); - } - - public function test_explode_with_array_value() - { - $payload = new Payload('field', 'in', ['apple', 'banana', 'orange'], ['apple', 'banana', 'orange']); - - $this->assertEquals(['apple', 'banana', 'orange'], $payload->explode(',')); - } - - public function test_split_method() - { - $payload = new Payload('field', '=', 'one|two|three', 'one|two|three'); - - $this->assertEquals(['one', 'two', 'three'], $payload->split('|')); - } - - public function test_as_like_throws_exception_for_invalid_side() - { - $this->expectException(\InvalidArgumentException::class); - - $payload = new Payload('field', 'like', 'search', 'search'); - $payload->asLike('invalid'); - } - - public function test_chaining_setter_methods() - { - $payload = new Payload('oldField', '=', 'oldValue', 'oldValue'); - - $result = $payload - ->setField('newField') - ->setOperator('!=') - ->setValue('newValue'); - - $this->assertInstanceOf(Payload::class, $result); - $this->assertEquals('newField', $payload->field); - $this->assertEquals('!=', $payload->operator); - $this->assertEquals('newValue', $payload->value); - } - - public function test_implements_arrayable_interface() - { - $this->assertInstanceOf(\Illuminate\Contracts\Support\Arrayable::class, $this->payload); - } - - public function test_implements_jsonable_interface() - { - $this->assertInstanceOf(\Illuminate\Contracts\Support\Jsonable::class, $this->payload); - } - - public function test_implements_stringable_interface() - { - $this->assertInstanceOf(\Stringable::class, $this->payload); - } - - public function test_macros() - { - Payload::macro('isEmail', function () { - return $this->regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); - }); - - $emailPayload = new Payload('field', '=', 'test@example.com', 'test@example.com'); - $nonEmailPayload = new Payload('field', '=', 'not-email', 'not-email'); - - $this->assertTrue($emailPayload->isEmail()); - $this->assertFalse($nonEmailPayload->isEmail()); - } - - public function test_handles_unicode_strings() - { - $payload = new Payload('field', '=', 'Hello δΈ–η•Œ', 'Hello δΈ–η•Œ'); - - $this->assertEquals(8, $payload->length()); - $this->assertTrue($payload->isString()); - // Slug removes non-ASCII characters if transliteration is not available - $slug = $payload->asSlug(); - $this->assertIsString($slug); - $this->assertStringContainsString('hello', $slug); - } - - public function test_handles_empty_array() - { - $payload = new Payload('field', 'in', [], []); - - $this->assertTrue($payload->isEmpty()); - $this->assertTrue($payload->isArray()); - $this->assertEquals(0, $payload->length()); - } - - public function test_handles_nested_json() - { - $nestedJson = '{"user":{"name":"John","address":{"city":"NYC"}}}'; - $payload = new Payload('field', '=', $nestedJson, $nestedJson); - - $this->assertTrue($payload->isJson()); - $this->assertEquals([ - 'user' => [ - 'name' => 'John', - 'address' => ['city' => 'NYC'] - ] - ], $payload->asArray()); - } - - public function test_handles_mixed_type_values() - { - $values = [ - 'string' => 'test', - 'integer' => 123, - 'float' => 45.67, - 'boolean' => true, - 'array' => ['a', 'b'], - 'null' => null, - ]; - - foreach ($values as $type => $value) { - $payload = new Payload('field', '=', $value, $value); - $this->assertInstanceOf(Payload::class, $payload); - $this->assertEquals($value, $payload->value); - } - } - - public function test_as_boolean_returns_null_for_non_boolean() - { - $payload = new Payload('field', '=', 'random', 'random'); - $this->assertNull($payload->asBoolean()); - } - - public function test_as_int_returns_null_for_non_numeric() - { - $payload = new Payload('field', '=', 'abc', 'abc'); - $this->assertNull($payload->asInt()); - } - - public function test_as_array_returns_null_for_non_array_non_json() - { - $payload = new Payload('field', '=', 'string', 'string'); - $this->assertNull($payload->asArray()); - } - - public function test_json_array_conversion() - { - $jsonArray = '["apple","banana","orange"]'; - $payload = new Payload('field', '=', $jsonArray, $jsonArray); - - $this->assertTrue($payload->isJson()); - $this->assertEquals(['apple', 'banana', 'orange'], $payload->asArray()); - } - - public function test_boolean_variations() - { - $variations = [ - ['value' => true, 'expected' => true], - ['value' => false, 'expected' => false], - ['value' => 1, 'expected' => true], - ['value' => 0, 'expected' => false], - ['value' => '1', 'expected' => true], - ['value' => '0', 'expected' => false], - ['value' => 'true', 'expected' => true], - ['value' => 'false', 'expected' => false], - ['value' => 'yes', 'expected' => true], - ['value' => 'no', 'expected' => false], - ]; - - foreach ($variations as $variation) { - $payload = new Payload('field', '=', $variation['value'], $variation['value']); - $this->assertTrue($payload->isBoolean()); - $this->assertEquals($variation['expected'], $payload->asBoolean()); - } - } - - public function test_numeric_variations() - { - $variations = [ - 123, - '123', - 45.67, - '45.67', - -100, - '-100', - ]; - - foreach ($variations as $value) { - $payload = new Payload('field', '=', $value, $value); - $this->assertTrue($payload->isNumeric()); - } - } - - public function test_date_variations() - { - $variations = [ - '2024-01-15', - '2024-01-15 10:30:00', - 'January 15, 2024', - 'tomorrow', - 'yesterday', - '+1 day', - ]; - - foreach ($variations as $value) { - $payload = new Payload('field', '=', $value, $value); - $this->assertTrue($payload->isDate(), "Failed asserting that '$value' is a valid date"); - } - } - - public function test_edge_case_empty_json_object() - { - $payload = new Payload('field', '=', '{}', '{}'); - - $this->assertTrue($payload->isJson()); - $this->assertEquals([], $payload->asArray()); - } - - public function test_edge_case_empty_json_array() - { - $payload = new Payload('field', '=', '[]', '[]'); - - $this->assertTrue($payload->isJson()); - $this->assertEquals([], $payload->asArray()); - } + protected Payload $payload; + + public function setUp(): void + { + parent::setUp(); + + $this->payload = new Payload( + field: 'name', + operator: '=', + value: 'Filterable', + rawValue: ' Filterable ' + ); + } + + public function test_it_can_be_instantiated() + { + $this->assertInstanceOf(Payload::class, $this->payload); + $this->assertEquals('name', $this->payload->field); + $this->assertEquals('=', $this->payload->operator); + $this->assertEquals('Filterable', $this->payload->value); + $this->assertEquals(' Filterable ', $this->payload->rawValue); + } + + public function test_static_create_method() + { + $payload = Payload::create('age', '>', 25, '25'); + $this->assertInstanceOf(Payload::class, $payload); + $this->assertEquals(25, $payload->value); + } + + public function test_length_method() + { + $this->assertEquals(10, $this->payload->length()); + + $arrayPayload = new Payload('tags', 'IN', ['php', 'laravel'], ['php', 'laravel']); + $this->assertEquals(2, $arrayPayload->length()); + } + + public function test_empty_and_not_empty() + { + $emptyPayload = new Payload('name', '=', '', ''); + $this->assertTrue($emptyPayload->isEmpty()); + $this->assertFalse($emptyPayload->isNotEmpty()); + + $this->assertTrue($this->payload->isNotEmpty()); + } + + public function test_null_check() + { + $nullPayload = new Payload('name', '=', null, null); + $this->assertTrue($nullPayload->isNull()); + } + + public function test_boolean_checks() + { + $truePayload = new Payload('active', '=', true, true); + $falsePayload = new Payload('active', '=', 'false', 'false'); + + $this->assertTrue($truePayload->isBoolean()); + $this->assertTrue($falsePayload->isBoolean()); + + $this->assertTrue($truePayload->isTrue()); + $this->assertTrue($falsePayload->isFalse()); + + $this->assertTrue($truePayload->asBoolean()); + $this->assertFalse($falsePayload->asBoolean()); + } + + public function test_json_checks() + { + $jsonPayload = new Payload('data', '=', '{"name":"John"}', '{"name":"John"}'); + $this->assertTrue($jsonPayload->isJson()); + $this->assertIsArray($jsonPayload->asArray()); + $this->assertEquals(['name' => 'John'], $jsonPayload->asArray()); + + $strictInvalid = new Payload('data', '=', '"string"', '"string"'); + $this->assertFalse($strictInvalid->isJson(true)); + $this->assertTrue($strictInvalid->isJson(false)); + } + + public function test_numeric_checks() + { + $numericPayload = new Payload('age', '=', '42', '42'); + $this->assertTrue($numericPayload->isNumeric()); + $this->assertEquals(42, $numericPayload->asInt()); + } + + public function test_string_and_array_checks() + { + $this->assertTrue($this->payload->isString()); + + $arrayPayload = new Payload('ids', 'IN', [1, 2, 3], [1, 2, 3]); + $this->assertTrue($arrayPayload->isArray()); + } + + public function test_wrap_and_like_helpers() + { + $this->assertEquals('%Filterable%', $this->payload->asLike()); + $this->assertEquals('%Filterable', $this->payload->asLike('start')); + $this->assertEquals('Filterable%', $this->payload->asLike('end')); + } + + public function test_to_array_and_json() + { + $array = $this->payload->toArray(); + $this->assertArrayHasKey('field', $array); + $this->assertArrayHasKey('operator', $array); + + $json = $this->payload->toJson(); + $this->assertJson($json); + } + + public function test_to_string_returns_value() + { + $this->assertEquals('Filterable', (string) $this->payload); + } + + public function test_raw_method_returns_before_sanitize_value() + { + $this->assertEquals(' Filterable ', $this->payload->raw()); + } + + public function test_is_empty_string() + { + $emptyString = new Payload('field', '=', ' ', ' '); + $this->assertTrue($emptyString->isEmptyString()); + + $this->assertFalse($this->payload->isEmptyString()); + } + + public function test_is_not_null_or_empty() + { + $this->assertTrue($this->payload->isNotNullOrEmpty()); + + $nullPayload = new Payload('field', '=', null, null); + $this->assertFalse($nullPayload->isNotNullOrEmpty()); + + $emptyPayload = new Payload('field', '=', '', ''); + $this->assertFalse($emptyPayload->isNotNullOrEmpty()); + } + + public function test_is_date() + { + $datePayload = new Payload('field', '=', '2024-01-15', '2024-01-15'); + $this->assertTrue($datePayload->isDate()); + + $nonDatePayload = new Payload('field', '=', 'not a date', 'not a date'); + $this->assertFalse($nonDatePayload->isDate()); + + $numberPayload = new Payload('field', '=', 123, 123); + $this->assertFalse($numberPayload->isDate()); + } + + public function test_is_timestamp() + { + $timestampPayload = new Payload('field', '=', 1705324800, 1705324800); + $this->assertTrue($timestampPayload->isTimestamp()); + + $timestampStringPayload = new Payload('field', '=', '1705324800', '1705324800'); + $this->assertTrue($timestampStringPayload->isTimestamp()); + + $nonTimestampPayload = new Payload('field', '=', 'not timestamp', 'not timestamp'); + $this->assertFalse($nonTimestampPayload->isTimestamp()); + } + + public function test_as_carbon() + { + $datePayload = new Payload('field', '=', '2024-01-15', '2024-01-15'); + $carbon = $datePayload->asCarbon(); + $this->assertInstanceOf(\Carbon\Carbon::class, $carbon); + + $timestampPayload = new Payload('field', '=', 1705324800, 1705324800); + $carbonFromTimestamp = $timestampPayload->asCarbon(); + $this->assertInstanceOf(\Carbon\Carbon::class, $carbonFromTimestamp); + + $nonDatePayload = new Payload('field', '=', 'invalid', 'invalid'); + $this->assertNull($nonDatePayload->asCarbon()); + } + + public function test_regex() + { + $emailPayload = new Payload('field', '=', 'john@example.com', 'john@example.com'); + $emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; + + $this->assertTrue($emailPayload->regex($emailPattern)); + + $nonEmailPayload = new Payload('field', '=', 'not-an-email', 'not-an-email'); + $this->assertFalse($nonEmailPayload->regex($emailPattern)); + + $numberPayload = new Payload('field', '=', 123, 123); + $this->assertFalse($numberPayload->regex($emailPattern)); + } + + public function test_as_slug() + { + $payload = new Payload('field', '=', 'Hello World Test', 'Hello World Test'); + + $this->assertEquals('hello-world-test', $payload->asSlug()); + $this->assertEquals('hello_world_test', $payload->asSlug('_')); + } + + public function test_in_method() + { + $payload = new Payload('field', '=', 'apple', 'apple'); + + $this->assertTrue($payload->in('apple', 'banana', 'orange')); + $this->assertTrue($payload->in(['apple', 'banana', 'orange'])); + $this->assertFalse($payload->in('banana', 'orange', 'grape')); + } + + public function test_not_in_method() + { + $payload = new Payload('field', '=', 'apple', 'apple'); + + $this->assertFalse($payload->notIn('apple', 'banana', 'orange')); + $this->assertTrue($payload->notIn('banana', 'orange', 'grape')); + } + + public function test_is_method_with_multiple_checks() + { + $jsonPayload = new Payload('field', '=', '["a","b"]', '["a","b"]'); + $this->assertTrue($jsonPayload->is('json', 'notEmpty')); + + $emptyPayload = new Payload('field', '=', '', ''); + $this->assertFalse($emptyPayload->is('json', 'notEmpty')); + } + + public function test_is_method_with_negation() + { + $stringPayload = new Payload('field', '=', 'value', 'value'); + + $this->assertTrue($stringPayload->is('!empty', 'string')); + $this->assertTrue($stringPayload->is('!null', '!numeric')); + } + + public function test_is_method_throws_exception_for_invalid_method() + { + $this->expectException(\InvalidArgumentException::class); + + $payload = new Payload('field', '=', 'value', 'value'); + $payload->is('invalidCheck'); + } + + public function test_is_any_method() + { + $numericPayload = new Payload('field', '=', '123', '123'); + + $this->assertTrue($numericPayload->isAny('numeric', 'boolean')); + $this->assertTrue($numericPayload->isAny('array', 'numeric')); + $this->assertFalse($numericPayload->isAny('array', 'boolean')); + } + + public function test_is_any_method_with_negation() + { + $stringPayload = new Payload('field', '=', 'value', 'value'); + + $this->assertTrue($stringPayload->isAny('!numeric', 'array')); + $this->assertTrue($stringPayload->isAny('!null', '!empty')); + } + + public function test_is_any_method_throws_exception_for_invalid_method() + { + $this->expectException(\InvalidArgumentException::class); + + $payload = new Payload('field', '=', 'value', 'value'); + $payload->isAny('invalidCheck'); + } + + public function test_set_value() + { + $this->payload->setValue('NewValue'); + $this->assertEquals('NewValue', $this->payload->value); + } + + public function test_set_field() + { + $this->payload->setField('newField'); + $this->assertEquals('newField', $this->payload->field); + } + + public function test_set_operator() + { + $this->payload->setOperator('!='); + $this->assertEquals('!=', $this->payload->operator); + } + + public function test_get_field() + { + $this->assertEquals('name', $this->payload->getField()); + } + + public function test_get_operator() + { + $this->assertEquals('=', $this->payload->getOperator()); + } + + public function test_explode_method() + { + $payload = new Payload('field', '=', 'apple,banana,orange', 'apple,banana,orange'); + + $this->assertEquals(['apple', 'banana', 'orange'], $payload->explode(',')); + $this->assertEquals(['apple,banana,orange'], $payload->explode('|')); + } + + public function test_explode_with_array_value() + { + $payload = new Payload('field', 'in', ['apple', 'banana', 'orange'], ['apple', 'banana', 'orange']); + + $this->assertEquals(['apple', 'banana', 'orange'], $payload->explode(',')); + } + + public function test_split_method() + { + $payload = new Payload('field', '=', 'one|two|three', 'one|two|three'); + + $this->assertEquals(['one', 'two', 'three'], $payload->split('|')); + } + + public function test_as_like_throws_exception_for_invalid_side() + { + $this->expectException(\InvalidArgumentException::class); + + $payload = new Payload('field', 'like', 'search', 'search'); + $payload->asLike('invalid'); + } + + public function test_chaining_setter_methods() + { + $payload = new Payload('oldField', '=', 'oldValue', 'oldValue'); + + $result = $payload + ->setField('newField') + ->setOperator('!=') + ->setValue('newValue'); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertEquals('newField', $payload->field); + $this->assertEquals('!=', $payload->operator); + $this->assertEquals('newValue', $payload->value); + } + + public function test_implements_arrayable_interface() + { + $this->assertInstanceOf(\Illuminate\Contracts\Support\Arrayable::class, $this->payload); + } + + public function test_implements_jsonable_interface() + { + $this->assertInstanceOf(\Illuminate\Contracts\Support\Jsonable::class, $this->payload); + } + + public function test_implements_stringable_interface() + { + $this->assertInstanceOf(\Stringable::class, $this->payload); + } + + public function test_macros() + { + Payload::macro('isEmail', function () { + return $this->regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + }); + + $emailPayload = new Payload('field', '=', 'test@example.com', 'test@example.com'); + $nonEmailPayload = new Payload('field', '=', 'not-email', 'not-email'); + + $this->assertTrue($emailPayload->isEmail()); + $this->assertFalse($nonEmailPayload->isEmail()); + } + + public function test_handles_unicode_strings() + { + $payload = new Payload('field', '=', 'Hello δΈ–η•Œ', 'Hello δΈ–η•Œ'); + + $this->assertEquals(8, $payload->length()); + $this->assertTrue($payload->isString()); + // Slug removes non-ASCII characters if transliteration is not available + $slug = $payload->asSlug(); + $this->assertIsString($slug); + $this->assertStringContainsString('hello', $slug); + } + + public function test_handles_empty_array() + { + $payload = new Payload('field', 'in', [], []); + + $this->assertTrue($payload->isEmpty()); + $this->assertTrue($payload->isArray()); + $this->assertEquals(0, $payload->length()); + } + + public function test_handles_nested_json() + { + $nestedJson = '{"user":{"name":"John","address":{"city":"NYC"}}}'; + $payload = new Payload('field', '=', $nestedJson, $nestedJson); + + $this->assertTrue($payload->isJson()); + $this->assertEquals([ + 'user' => [ + 'name' => 'John', + 'address' => ['city' => 'NYC'], + ], + ], $payload->asArray()); + } + + public function test_handles_mixed_type_values() + { + $values = [ + 'string' => 'test', + 'integer' => 123, + 'float' => 45.67, + 'boolean' => true, + 'array' => ['a', 'b'], + 'null' => null, + ]; + + foreach ($values as $type => $value) { + $payload = new Payload('field', '=', $value, $value); + $this->assertInstanceOf(Payload::class, $payload); + $this->assertEquals($value, $payload->value); + } + } + + public function test_as_boolean_returns_null_for_non_boolean() + { + $payload = new Payload('field', '=', 'random', 'random'); + $this->assertNull($payload->asBoolean()); + } + + public function test_as_int_returns_null_for_non_numeric() + { + $payload = new Payload('field', '=', 'abc', 'abc'); + $this->assertNull($payload->asInt()); + } + + public function test_as_array_returns_null_for_non_array_non_json() + { + $payload = new Payload('field', '=', 'string', 'string'); + $this->assertNull($payload->asArray()); + } + + public function test_json_array_conversion() + { + $jsonArray = '["apple","banana","orange"]'; + $payload = new Payload('field', '=', $jsonArray, $jsonArray); + + $this->assertTrue($payload->isJson()); + $this->assertEquals(['apple', 'banana', 'orange'], $payload->asArray()); + } + + public function test_boolean_variations() + { + $variations = [ + ['value' => true, 'expected' => true], + ['value' => false, 'expected' => false], + ['value' => 1, 'expected' => true], + ['value' => 0, 'expected' => false], + ['value' => '1', 'expected' => true], + ['value' => '0', 'expected' => false], + ['value' => 'true', 'expected' => true], + ['value' => 'false', 'expected' => false], + ['value' => 'yes', 'expected' => true], + ['value' => 'no', 'expected' => false], + ]; + + foreach ($variations as $variation) { + $payload = new Payload('field', '=', $variation['value'], $variation['value']); + $this->assertTrue($payload->isBoolean()); + $this->assertEquals($variation['expected'], $payload->asBoolean()); + } + } + + public function test_numeric_variations() + { + $variations = [ + 123, + '123', + 45.67, + '45.67', + -100, + '-100', + ]; + + foreach ($variations as $value) { + $payload = new Payload('field', '=', $value, $value); + $this->assertTrue($payload->isNumeric()); + } + } + + public function test_date_variations() + { + $variations = [ + '2024-01-15', + '2024-01-15 10:30:00', + 'January 15, 2024', + 'tomorrow', + 'yesterday', + '+1 day', + ]; + + foreach ($variations as $value) { + $payload = new Payload('field', '=', $value, $value); + $this->assertTrue($payload->isDate(), "Failed asserting that '$value' is a valid date"); + } + } + + public function test_edge_case_empty_json_object() + { + $payload = new Payload('field', '=', '{}', '{}'); + + $this->assertTrue($payload->isJson()); + $this->assertEquals([], $payload->asArray()); + } + + public function test_edge_case_empty_json_array() + { + $payload = new Payload('field', '=', '[]', '[]'); + + $this->assertTrue($payload->isJson()); + $this->assertEquals([], $payload->asArray()); + } }