diff --git a/README.md b/README.md index 63610e0b..4848ceff 100644 --- a/README.md +++ b/README.md @@ -136,23 +136,25 @@ Available annotations: `#[Authorize]` `#[SkipIf]` `#[Cast]` `#[Sanitize]` `#[Tri ### Ruleset Engine -Flat field-operator-value format, ideal for REST APIs where the frontend controls which operator to use. +Field-operator-value format, including nested relational fields, ideal for REST APIs where the frontend controls which operator to use. ``` GET /posts?filter[status]=published GET /posts?filter[title][like]=%laravel% GET /posts?filter[views][gte]=100 GET /posts?filter[id][in][]=1&filter[id][in][]=2 +GET /posts?filter[tags][name]=featured ``` Supported operators: `eq` `neq` `gt` `gte` `lt` `lte` `like` `nlike` `in` `between` ### Expression Engine -Everything Ruleset does, plus filtering through deep Eloquent relationships using dot notation. +Everything Ruleset does, plus filtering through deep Eloquent relationships using dot notation or nested request keys. ``` GET /posts?filter[author.profile.name][like]=ahmed +GET /posts?filter[author][profile][name][like]=ahmed ``` ```php diff --git a/docs/engines/expression.md b/docs/engines/expression.md index d9bc54e5..c441702c 100644 --- a/docs/engines/expression.md +++ b/docs/engines/expression.md @@ -10,6 +10,7 @@ It is ideal when you want the power of RuleSet-style syntax but also need to fil ```http GET /posts?filter[status]=pending&filter[author.profile.name][like]=kettasoft +GET /posts?filter[author][profile][name][like]=kettasoft ``` This will: @@ -25,7 +26,7 @@ This will: - Each filter can be a: - Simple key-value pair (e.g., `filter[status]=active`) - Operator-based pair (e.g., `filter[name][like]=kettasoft`) - - Nested relation filter (e.g., `filter[author.profile.name]=ahmed`) + - Nested relation filter using dot notation or nested request keys (e.g., `filter[author][profile][name]=ahmed`) - The engine determines the filter structure and applies the corresponding query constraints. @@ -45,7 +46,7 @@ This default is configurable in the engine settings. ## ✅ Supported Features - ✅ Flat and nested filters -- ✅ Dot notation for relationships (e.g., `author.profile.name`) +- ✅ Dot notation and nested request arrays for relationships - ✅ Customizable default operator - ✅ Whitelisting of allowed fields & relations - ✅ Works well with eager loading and relationship validation @@ -65,6 +66,17 @@ Filterable::create()->useEngine('expression') ])->paginate() ``` +Relation authorization supports several forms: + +```php +->allowRelations(['author']) // Every field below author +->allowRelations(['author' => ['name', 'email']]) // Selected fields +->allowRelations(['author' => ['*']]) // Explicit field wildcard +->allowRelations(['author.profile' => ['name']]) // Deep relation +``` + +Only configured relation paths are flattened. Ordinary array values, operator expressions, and structured conditions remain unchanged. + In **strict mode**, unsupported fields will be rejected with a validation error. --- @@ -79,7 +91,7 @@ Post::filter($filters, Expression::class)->get(); ## 🧠 Internal Logic (Simplified) -- Parse the `filter` array recursively. +- Normalize configured nested relation fields to dot notation. - Detect relationships via dot notation. - Resolve the relation path and apply `whereHas` queries for related models. - Build appropriate SQL queries via the Eloquent builder. diff --git a/docs/engines/rule-set.md b/docs/engines/rule-set.md index 1e3b3281..b2b66c20 100644 --- a/docs/engines/rule-set.md +++ b/docs/engines/rule-set.md @@ -2,7 +2,7 @@ The **Ruleset Engine** is a straightforward filtering strategy that interprets filters as flat rule arrays. It's especially suitable for simple request formats, where each filter targets a specific field using one or more operators. -This engine is ideal for APIs and frontends that send clean key-value pairs or use operator-based nesting. +This engine is ideal for APIs and frontends that send clean key-value pairs, use operator-based nesting, or submit relational fields as nested arrays. --- @@ -47,6 +47,28 @@ This will be interpreted as: ['name' => ['like' => 'kettasoft']] ``` +#### 🔹 Format 3: Relational field + +Relational fields may use either nested request keys or dot notation: + +```http +/posts?filter[tags][name]=featured +/posts?filter[tags.name]=featured +``` + +Authorize the relation before applying the request: + +```php +Filterable::for(Post::class, $request) + ->using('ruleset') + ->allowRelations(['tags' => ['name']]) + ->get(); +``` + +Nested input is converted to `tags.name` internally. Operator and list arrays remain intact, so requests such as `filter[tags][name][like]=%php%` and `filter[tags][id][in][]=1` work as expected. + +Use `['tags']` to allow every field on a relation, `['tags' => ['*']]` for an explicit field wildcard, or `['tags.post' => ['status']]` for a deep relation. + --- ### 🛠 Operator Resolution @@ -108,5 +130,6 @@ If any validation fails, an exception will be thrown instead of silently ignorin ### 🌿 Best Practices - Always define `allowed fields` and `allowed operators` in your filter class. +- Prefer field-specific relation definitions such as `['tags' => ['name']]` when the client does not need access to every related field. - Use request validation or sanitizers to clean filter input before applying to query. - Avoid exposing sensitive fields via filters unless explicitly allowed. diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index 1892a28f..7d2ca77e 100644 --- a/src/Engines/Expression.php +++ b/src/Engines/Expression.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Support\RelationFieldParser; use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Support\ConditionNormalizer; use Kettasoft\Filterable\Support\ValidateTableColumns; @@ -27,7 +28,11 @@ class Expression extends Engine */ public function execute(Builder $builder): Builder { - $filters = $this->context->getData(); + $filters = RelationFieldParser::parse( + $this->context->getData(), + $this->context->getRelations(), + array_keys($this->allowedOperators()) + ); foreach ($filters as $field => $condition) { $this->attempt(function () use ($builder, $field, $condition) { diff --git a/src/Engines/Foundation/PayloadFactory.php b/src/Engines/Foundation/PayloadFactory.php index 90a91d14..3b0b8bfd 100644 --- a/src/Engines/Foundation/PayloadFactory.php +++ b/src/Engines/Foundation/PayloadFactory.php @@ -13,10 +13,21 @@ */ class PayloadFactory { + /** + * Create a new PayloadFactory instance. + * + * @param Engine $engine The engine instance to use for validation and resolution. + */ public function __construct(protected Engine $engine) {} /** * Validate and resolve the given payload. + * + * @param Payload $payload The payload to validate and resolve. + * @return Payload The validated and resolved payload. + * @throws NotAllowedFieldException If the field is not allowed. + * @throws InvalidOperatorException If the operator is not allowed and strict mode is enabled. + * @throws NotAllowedEmptyValueException If empty values are not allowed and the payload is */ public function make(Payload $payload): Payload { @@ -29,16 +40,33 @@ public function make(Payload $payload): Payload ->setOperator($this->resolveOperator($payload)); } + /** + * Validate the field of the payload. + * + * @param Payload $payload The payload containing the field to validate. + * @throws NotAllowedFieldException If the field is not allowed. + */ protected function validateField(Payload $payload): void { $field = $payload->field; - $isWildcardAllowed = ($this->engine->getAllowedFields()[0] ?? false) === '*'; + $allowedFields = $this->engine->getAllowedFields(); - if (!(in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field) || $isWildcardAllowed)) { + if (in_array('*', $allowedFields, true)) { + return; + } + + if (!(in_array($field, $allowedFields, true) || $this->isRelational($field))) { throw new NotAllowedFieldException($field, $payload); } } + /** + * Validate the operator of the payload. + * + * @param Payload $payload The payload containing the operator to validate. + * @return bool True if the operator is valid, false otherwise. + * @throws InvalidOperatorException If the operator is not allowed and strict mode is enabled. + */ protected function validateOperator(Payload $payload): bool { $operator = $payload->operator; @@ -50,6 +78,11 @@ protected function validateOperator(Payload $payload): bool return (bool) $this->engine->defaultOperator(); } + /** + * Validate the value of the payload. + * + * @throws NotAllowedEmptyValueException if empty values are not allowed and the payload is empty. + */ protected function validateValue(Payload $payload): void { if ($this->engine->isIgnoredEmptyValues() && $payload->isEmpty()) { @@ -57,17 +90,35 @@ protected function validateValue(Payload $payload): void } } + /** + * Resolve the field name based on the engine's field mapping. + * + * @param Payload $payload The payload containing the field to resolve. + * @return string The resolved field name. + */ protected function resolveField(Payload $payload): string { return $this->engine->getFieldsMap()[$payload->field] ?? $payload->field; } + /** + * Resolve the operator based on the engine's allowed operators. + * + * @param Payload $payload The payload containing the operator to resolve. + * @return string The resolved operator. + */ protected function resolveOperator(Payload $payload): string { return $this->engine->allowedOperators()[$payload->operator] ?? Operators::fromString($this->engine->defaultOperator()); } + /** + * Determines if the given field is a relational field. + * + * @param string $field The field to check. + * @return bool True if the field is relational, false otherwise. + */ protected function isRelational(string $field): bool { return $this->engine->getContext()->hasRelationPath($field); diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index 0eefe280..70ad9e5d 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Support\RelationFieldParser; use Kettasoft\Filterable\Traits\FieldNormalizer; use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Engines\Foundation\PayloadApplier; @@ -28,7 +29,11 @@ class Ruleset extends Engine */ public function execute(Builder $builder): Builder { - $data = $this->context->getData(); + $data = RelationFieldParser::parse( + $this->context->getData(), + $this->context->getRelations(), + array_keys($this->allowedOperators()) + ); foreach ($data as $field => $dissector) { $this->attempt(function () use ($builder, $dissector, $field): bool { diff --git a/src/Support/RelationFieldParser.php b/src/Support/RelationFieldParser.php new file mode 100644 index 00000000..b8972dbe --- /dev/null +++ b/src/Support/RelationFieldParser.php @@ -0,0 +1,105 @@ + $operators Recognized operator aliases. + */ + public static function parse(array $data, array $relations, array $operators = []): array + { + return static::flatten($data, $relations, $operators); + } + + /** + * Flattens a nested array into dot-notated paths. + * + * @param array $data The data to flatten. + * @param array $relations Allowed relation definitions. + * @param array $operators Recognized operator aliases. + * @param string $prefix The prefix for the current level of recursion. + * @return array The flattened array with dot-notated paths as keys. + */ + protected static function flatten( + array $data, + array $relations, + array $operators, + string $prefix = '' + ): array { + $result = []; + + foreach ($data as $key => $value) { + $path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; + + if ( + !is_array($value) + || $value === [] + || static::isCondition($value, $operators) + || !static::isRelationPrefix($path, $relations) + ) { + $result[$path] = $value; + continue; + } + + $result = array_merge( + $result, + static::flatten($value, $relations, $operators, $path) + ); + } + + return $result; + } + + /** + * Determines if the given value is a condition. + * + * @param array $value The value to check. + * @param array $operators Recognized operator aliases. + */ + protected static function isCondition(array $value, array $operators): bool + { + if (array_is_list($value)) { + return true; + } + + if (array_key_exists('operator', $value) && array_key_exists('value', $value)) { + return true; + } + + return count($value) === 1 + && in_array((string) array_key_first($value), $operators, true); + } + + /** + * Determines if the given path is a prefix of any relation. + * + * @param string $path The path to check. + * @param array $relations Allowed relation definitions. + */ + protected static function isRelationPrefix(string $path, array $relations): bool + { + $root = explode('.', $path, 2)[0]; + + foreach ($relations as $relation => $fields) { + if (is_int($relation)) { + if ($fields === $root) { + return true; + } + + continue; + } + + if ($relation === $path || str_starts_with($relation, "{$path}.")) { + return true; + } + } + + return false; + } +} diff --git a/src/Traits/InteractsWithRelationsFiltering.php b/src/Traits/InteractsWithRelationsFiltering.php index 11cf9e5e..b03c3abc 100644 --- a/src/Traits/InteractsWithRelationsFiltering.php +++ b/src/Traits/InteractsWithRelationsFiltering.php @@ -2,8 +2,6 @@ namespace Kettasoft\Filterable\Traits; -use Illuminate\Support\Arr; - trait InteractsWithRelationsFiltering { /** @@ -41,8 +39,18 @@ public function setRelations(array $relations, bool $override = false): static */ 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; + $root = explode('.', $relation, 2)[0]; + + foreach ($this->relations as $allowedRelation => $fields) { + if (is_int($allowedRelation) && $fields === $root) { + return true; + } + + if ($allowedRelation !== $relation || !is_array($fields)) { + continue; + } + + return in_array('*', $fields, true) || in_array($field, $fields, true); } return false; @@ -63,7 +71,7 @@ public function getRelations(): array * @param string $path * @return bool */ - public function hasRelationPath(string $path) + public function hasRelationPath(string $path): bool { if (str_contains($path, '.')) { @@ -73,11 +81,7 @@ public function hasRelationPath(string $path) $path = implode('.', $relations); - if (Arr::isAssoc($this->relations)) { - return isset($this->relations[$path]) && in_array($field, $this->relations[$path]); - } - - return in_array($relations[0], $this->relations); + return $this->isRelationAllowed($path, $field); } return false; diff --git a/tests/Unit/Filterable/RelationalFieldsTest.php b/tests/Unit/Filterable/RelationalFieldsTest.php new file mode 100644 index 00000000..5b200478 --- /dev/null +++ b/tests/Unit/Filterable/RelationalFieldsTest.php @@ -0,0 +1,160 @@ +seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'tags' => ['name' => 'featured'], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->allowRelations(['tags']) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame($active->id, $posts->first()->id); + } + + public function test_expression_filters_a_nested_relation_condition() + { + [$active] = $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'filter' => [ + 'tags' => [ + 'name' => ['eq' => 'featured'], + ], + ], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('expression') + ->allowRelations(['tags' => ['name']]) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame($active->id, $posts->first()->id); + } + + public function test_ruleset_filters_a_deep_nested_relation_array() + { + [$active] = $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'tags' => [ + 'post' => [ + 'status' => 'active', + ], + ], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->allowRelations(['tags.post' => ['status']]) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame($active->id, $posts->first()->id); + } + + public function test_nested_parser_does_not_break_structured_ruleset_conditions() + { + $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'status' => ['operator' => 'eq', 'value' => 'active'], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields(['status']) + ->allowRelations(['tags']) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame('active', $posts->first()->status); + } + + public function test_associative_relation_fields_reject_unlisted_fields() + { + $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'tags' => ['name' => 'featured'], + ]); + + $this->expectException(NotAllowedFieldException::class); + + Filterable::for(Post::class, $request) + ->using('ruleset') + ->strict() + ->allowRelations(['tags' => ['id']]) + ->get(); + } + + public function test_relation_field_wildcard_allows_any_field() + { + [$active] = $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', [ + 'tags' => ['name' => 'featured'], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->allowRelations(['tags' => ['*']]) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame($active->id, $posts->first()->id); + } + + public function test_field_wildcard_is_recognized_in_any_position() + { + $this->seedPostsWithTags(); + $request = Request::create('/posts', 'GET', ['title' => 'Active post']); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields(['status', '*']) + ->get(); + + $this->assertCount(1, $posts); + } + + public function test_mixed_relation_definitions_are_authorized_independently() + { + $filterable = Filterable::for(Post::class)->allowRelations([ + 'comments', + 'tags' => ['name'], + ]); + + $this->assertTrue($filterable->hasRelationPath('comments.body')); + $this->assertTrue($filterable->hasRelationPath('tags.name')); + $this->assertFalse($filterable->hasRelationPath('tags.id')); + } + + private function seedPostsWithTags(): array + { + $active = Post::factory()->create([ + 'title' => 'Active post', + 'status' => 'active', + ]); + $pending = Post::factory()->create([ + 'title' => 'Pending post', + 'status' => 'pending', + ]); + + Tag::factory()->create(['post_id' => $active->id, 'name' => 'featured']); + Tag::factory()->create(['post_id' => $pending->id, 'name' => 'archived']); + + return [$active, $pending]; + } +} diff --git a/tests/Unit/Support/RelationFieldParserTest.php b/tests/Unit/Support/RelationFieldParserTest.php new file mode 100644 index 00000000..5499f98b --- /dev/null +++ b/tests/Unit/Support/RelationFieldParserTest.php @@ -0,0 +1,90 @@ + ['name' => 'featured'], + 'metadata' => ['locale' => 'en'], + ], ['tags']); + + $this->assertSame([ + 'tags.name' => 'featured', + 'metadata' => ['locale' => 'en'], + ], $result); + } + + public function test_it_flattens_deep_associative_relation_definitions() + { + $result = RelationFieldParser::parse([ + 'tags' => [ + 'post' => [ + 'status' => 'active', + ], + ], + ], ['tags.post' => ['status']]); + + $this->assertSame(['tags.post.status' => 'active'], $result); + } + + public function test_it_preserves_structured_ruleset_conditions() + { + $condition = ['operator' => 'eq', 'value' => 'active']; + + $result = RelationFieldParser::parse([ + 'status' => $condition, + 'tags' => ['name' => $condition], + ], ['tags'], ['eq']); + + $this->assertSame([ + 'status' => $condition, + 'tags.name' => $condition, + ], $result); + } + + public function test_it_preserves_expression_conditions_and_list_values() + { + $result = RelationFieldParser::parse([ + 'tags' => [ + 'name' => ['like' => '%php%'], + 'status' => ['active', 'pending'], + ], + ], ['tags'], ['eq', 'like', 'in']); + + $this->assertSame([ + 'tags.name' => ['like' => '%php%'], + 'tags.status' => ['active', 'pending'], + ], $result); + } + + public function test_it_preserves_empty_arrays() + { + $result = RelationFieldParser::parse([ + 'tags' => ['name' => []], + ], ['tags']); + + $this->assertSame(['tags.name' => []], $result); + } + + public function test_it_supports_mixed_list_and_associative_relation_definitions() + { + $result = RelationFieldParser::parse([ + 'comments' => ['body' => 'approved'], + 'tags' => ['name' => 'featured'], + ], [ + 'comments', + 'tags' => ['name'], + ]); + + $this->assertSame([ + 'comments.body' => 'approved', + 'tags.name' => 'featured', + ], $result); + } +}