diff --git a/config/query-builder.php b/config/query-builder.php index ca770550..a022e9f5 100644 --- a/config/query-builder.php +++ b/config/query-builder.php @@ -81,4 +81,18 @@ * Set this to `true` if you want to convert the firstName into first_name for the underlying query */ 'convert_field_names_to_snake_case' => false, + + /* + * Determines how filter property names are converted to scope method names. + * + * Available strategies: + * - 'camel': Converts 'published_in_year' to 'publishedInYear' (default) + * - 'none': Uses the exact name without conversion + * - 'auto': Tries exact match first, falls back to camelCase + * + * Example: GET /posts?filter[published_in_year]=2023 + * - 'camel' calls: scopePublishedInYear() + * - 'none' calls: scopePublished_in_year() + */ + 'scope_name_converter' => 'camel', ]; diff --git a/src/Filters/FiltersScope.php b/src/Filters/FiltersScope.php index 5be5d508..c05ee2ad 100644 --- a/src/Filters/FiltersScope.php +++ b/src/Filters/FiltersScope.php @@ -24,7 +24,9 @@ public function __invoke(Builder $query, mixed $values, string $property): Build { $propertyParts = collect(explode('.', $property)); - $scope = Str::camel($propertyParts->pop()); // TODO: Make this configurable? + $rawScope = $propertyParts->pop(); + + $scope = $this->resolveScopeName($query, $rawScope); $values = array_values(Arr::wrap($values)); $values = $this->resolveParameters($query, $values, $scope); @@ -75,6 +77,35 @@ protected function resolveParameters(Builder $query, $values, string $scope): ar return $values; } + protected function resolveScopeName(Builder $query, string $rawScope): string + { + $converter = config('query-builder.scope_name_converter', 'camel'); + + return match ($converter) { + 'none' => $rawScope, + 'auto' => $this->autoResolveScopeName($query, $rawScope), + default => Str::camel($rawScope), + }; + } + + protected function autoResolveScopeName(Builder $query, string $rawScope): string + { + $model = $query->getModel(); + + $exactMethod = 'scope' . ucfirst($rawScope); + if (method_exists($model, $exactMethod)) { + return $rawScope; + } + + $camelized = Str::camel($rawScope); + $camelMethod = 'scope' . ucfirst($camelized); + if (method_exists($model, $camelMethod)) { + return $camelized; + } + + return $camelized; + } + protected function getClass(ReflectionParameter $parameter): ?ReflectionClass { $type = $parameter->getType(); diff --git a/tests/FilterTest.php b/tests/FilterTest.php index e7e28c6c..9e4703b2 100644 --- a/tests/FilterTest.php +++ b/tests/FilterTest.php @@ -908,3 +908,62 @@ public function __invoke(Builder $query, $value, string $property): Builder expect($results)->toHaveCount(2); }); + +it('converts snake case filter names to camel case scope by default', function () { + Carbon::setTestNow(Carbon::parse('2016-05-05')); + TestModel::create(['name' => 'John Testing Doe']); + + // 'created_between' should be converted to 'createdBetween' scope + $modelsResult = createQueryFromFilterRequest(['created_between' => '2016-01-01,2017-01-01']) + ->allowedFilters(AllowedFilter::scope('created_between')) + ->get(); + + expect($modelsResult)->toHaveCount(1); +}); + +it('respects scope name converter config with none option', function () { + $originalConfig = config('query-builder.scope_name_converter'); + config(['query-builder.scope_name_converter' => 'none']); + + TestModel::create(['name' => 'John Testing Doe']); + + $modelsResult = createQueryFromFilterRequest(['named' => 'John Testing Doe']) + ->allowedFilters(AllowedFilter::scope('named')) + ->get(); + + expect($modelsResult)->toHaveCount(1); + + config(['query-builder.scope_name_converter' => $originalConfig]); +}); + +it('uses auto converter to detect exact scope method name', function () { + $originalConfig = config('query-builder.scope_name_converter'); + config(['query-builder.scope_name_converter' => 'auto']); + + TestModel::create(['name' => 'John Testing Doe']); + + $modelsResult = createQueryFromFilterRequest(['named' => 'John Testing Doe']) + ->allowedFilters(AllowedFilter::scope('named')) + ->get(); + + expect($modelsResult)->toHaveCount(1); + + config(['query-builder.scope_name_converter' => $originalConfig]); +}); + +it('auto converter falls back to camel case when exact method not found', function () { + $originalConfig = config('query-builder.scope_name_converter'); + config(['query-builder.scope_name_converter' => 'auto']); + + Carbon::setTestNow(Carbon::parse('2016-05-05')); + TestModel::create(['name' => 'John Testing Doe']); + + // 'created_between' exact scope doesn't exist, should fallback to 'createdBetween' + $modelsResult = createQueryFromFilterRequest(['created_between' => '2016-01-01,2017-01-01']) + ->allowedFilters(AllowedFilter::scope('created_between')) + ->get(); + + expect($modelsResult)->toHaveCount(1); + + config(['query-builder.scope_name_converter' => $originalConfig]); +});