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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions docs/engines/expression.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

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

---
Expand All @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion docs/engines/rule-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
7 changes: 6 additions & 1 deletion src/Engines/Expression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
55 changes: 53 additions & 2 deletions src/Engines/Foundation/PayloadFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
Expand All @@ -50,24 +78,47 @@ 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()) {
throw new NotAllowedEmptyValueException('Empty values are not allowed.', $payload);
}
}

/**
* 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);
Expand Down
7 changes: 6 additions & 1 deletion src/Engines/Ruleset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
105 changes: 105 additions & 0 deletions src/Support/RelationFieldParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace Kettasoft\Filterable\Support;

/**
* Converts nested relation input into dot-notated field paths.
*/
class RelationFieldParser
{
/**
* @param array $data
* @param array $relations Allowed relation definitions.
* @param array<string> $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<string> $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<string> $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;
}
}
Loading
Loading