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
30 changes: 30 additions & 0 deletions docs/api/filterable.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,36 @@ $payload->rawValue; // original request value

---

### Skipped Payloads

#### `skipped(?string $field = null): array`

Get every skipped payload, or only entries for a specific field. Each entry
contains a payload snapshot, the skip reason, field, value, and
timestamp.

#### `hasSkipped(string $field): bool`

Determine whether a payload for the given field was skipped.

```php
$filter = PostFilter::create();
$filter->apply(Post::query());

if ($filter->hasSkipped('status')) {
$entry = $filter->skipped('status')[0];

$entry['payload']; // Payload snapshot
$entry['reason']; // Why the filter was skipped
$entry['timestamp']; // Carbon timestamp
}
```

Skipped payloads are recorded in both permissive and strict modes. Strict mode
still rethrows the associated `SkipExecution` exception after recording it.

---

### Flow Control

#### `when(bool $condition, callable $callback): static`
Expand Down
10 changes: 7 additions & 3 deletions docs/engines/invokable/custom-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ use Kettasoft\Filterable\Engines\Exceptions\SkipExecution;
use Kettasoft\Filterable\Engines\Exceptions\StrictnessException;

// Silent skip
throw new SkipExecution('Value too short.');
throw new SkipExecution('Value too short.', $context->payload);

// Hard fail
throw new StrictnessException('This field is required.');
Expand Down Expand Up @@ -127,7 +127,8 @@ class MinLength implements MethodAttribute

if (is_string($value) && mb_strlen($value) < $this->length) {
throw new SkipExecution(
"Value must be at least {$this->length} characters."
"Value must be at least {$this->length} characters.",
$context->payload
);
}
}
Expand Down Expand Up @@ -207,7 +208,10 @@ class OnlyWhen implements MethodAttribute
public function handle(AttributeContext $context): void
{
if (! auth()->user()?->hasRole($this->role)) {
throw new SkipExecution("User does not have role: {$this->role}");
throw new SkipExecution(
"User does not have role: {$this->role}",
$context->payload
);
}
}
}
Expand Down
11 changes: 9 additions & 2 deletions docs/engines/invokable/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,23 @@ public function test_title_filter_uses_like_operator(): void
## Testing Skipped Filters

When a filter is skipped (e.g. via `#[SkipIf]`, `#[In]`, or `#[Authorize]`),
the clause should not appear in the query:
the condition should not appear in the query and its payload should be recorded:

```php
public function test_status_filter_is_skipped_when_value_is_invalid(): void
{
$request = $this->makeRequest(['status' => 'invalid_status']);

$query = Post::filter(PostFilter::class, $request);
$filter = new PostFilter($request);
$query = Post::filter($filter);

$this->assertStringNotContainsString('status', $query->toSql());
$this->assertTrue($filter->hasSkipped('status'));

$skipped = $filter->skipped('status')[0];

$this->assertSame('status', $skipped['payload']->field);
$this->assertNotEmpty($skipped['reason']);
}
```

Expand Down
9 changes: 5 additions & 4 deletions src/Engines/Contracts/Skippable.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
interface Skippable
{
/**
* Skip the current execution with a message and optional payload.
* @param string $message
* @param Payload|null $payload
* Skip the current payload execution.
*
* @param Payload $payload The payload being skipped
* @param string|null $message The reason for skipping
* @throws SkipExecution
* @return never
*/
public function skip(string $message, ?Payload $payload = null): never;
public function skip(Payload $payload, ?string $message = null): never;
}
1 change: 1 addition & 0 deletions src/Engines/Exceptions/InvalidOperatorException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class InvalidOperatorException extends SkipExecution
/**
* InvalidOperatorException constructor.
* @param string $operator
* @param Payload|null $payload
*/
public function __construct(string $operator, ?Payload $payload = null)
{
Expand Down
5 changes: 3 additions & 2 deletions src/Engines/Exceptions/NotAllowedEmptyValueException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ class NotAllowedEmptyValueException extends SkipExecution
{
/**
* NotAllowedEmptyValueException constructor.
* @param mixed $message
* @param string $message
* @param Payload|null $payload
*/
public function __construct($message = "", ?Payload $payload = null)
public function __construct(string $message = "", ?Payload $payload = null)
{
parent::__construct($message, $payload);
}
Expand Down
1 change: 1 addition & 0 deletions src/Engines/Exceptions/NotAllowedFieldException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class NotAllowedFieldException extends SkipExecution
/**
* NotAllowedFieldException constructor.
* @param string $field
* @param Payload|null $payload
*/
public function __construct(string $field, ?Payload $payload = null)
{
Expand Down
3 changes: 2 additions & 1 deletion src/Engines/Exceptions/SkipExecution.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public function __construct(string $message, protected ?Payload $payload = null)
}

/**
* Get the associated payload.
* Get the associated payload that was skipped.
* @return Payload|null
*/
public function getPayload(): ?Payload
{
Expand Down
5 changes: 4 additions & 1 deletion src/Engines/Foundation/Attributes/Annotations/Authorize.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri
$authorize = new $this->authorize;

if (! $authorize->authorize()) {
throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'.");
throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution(
"Authorization failed for class '{$this->authorize}'.",
$context->payload
);
}
}
}
6 changes: 4 additions & 2 deletions src/Engines/Foundation/Attributes/Annotations/Between.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,17 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri

if (! is_numeric($payload->value)) {
throw new SkipExecution(
"The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}."
"The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}.",
$payload
);
}

$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}."
"The value '{$value}' is not between {$this->min} and {$this->max}.",
$payload
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Engines/Foundation/Attributes/Annotations/In.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri

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)
"The value '{$payload->value}' is not in the allowed set: " . implode(', ', $this->values),
$payload
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Engines/Foundation/Attributes/Annotations/MapValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri

if ($this->strict) {
throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution(
"The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map))
"The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map)),
$payload
);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/Engines/Foundation/Attributes/Annotations/Regex.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri

if (! is_string($payload->value)) {
throw new SkipExecution(
$this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'."
$this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'.",
$payload
);
}

if (! preg_match($this->pattern, $payload->value)) {
throw new SkipExecution(
$this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'."
$this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'.",
$payload
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Engines/Foundation/Attributes/Annotations/SkipIf.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri

if ($result) {
throw new SkipExecution(
$this->message ?: "Filter skipped because payload {$this->check} check was true."
$this->message ?: "Filter skipped because payload {$this->check} check was true.",
$payload
);
}
}
Expand Down
11 changes: 8 additions & 3 deletions src/Engines/Foundation/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,16 @@ final protected function attempt(\Closure $callback): bool
}

/**
* @inheritDoc
* Skip the current filter execution with a message and payload.
*
* @param \Kettasoft\Filterable\Support\Payload $payload The payload being skipped
* @param string|null $message The reason for skipping
* @return never
* @throws SkipExecution
*/
public function skip(string $message, ?Payload $payload = null): never
public function skip(Payload $payload, ?string $message = null): never
{
throw new SkipExecution($message, $payload);
throw new SkipExecution($message ?? 'Filter execution skipped.', $payload);
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/Exceptions/Handlers/DefaultHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public function handle(\Throwable|SkipExecution $exception, Engine $engine): boo
if ($this->hasSkipping($exception)) {
/** @var SkipExecution $exception */

// Register the skipped payload in the context
if ($payload = $exception->getPayload()) {
$engine->getContext()->skip($payload, $exception->getMessage());
}

if ($engine->isStrict() || $this->isStrictThrowing()) {
throw $exception;
}
Expand Down
53 changes: 53 additions & 0 deletions src/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit
*/
protected $applied = [];

/**
* Skipped payloads.
* @var array<int, array{payload: Payload, reason: string|null, field: string, value: mixed, timestamp: \Carbon\Carbon}>
*/
protected array $skipped = [];

/**
* Create a new Filterable instance.
* @param Request|null $request
Expand Down Expand Up @@ -281,6 +287,53 @@ public function commit(string $key, Payload $payload): bool
return true;
}

/**
* Register a skipped payload.
* @param Payload $payload
* @param string|null $reason Optional reason for skipping
* @return bool
*/
public function skip(Payload $payload, ?string $reason = null): bool
{
$payload = clone $payload;

$this->skipped[] = [
'payload' => $payload,
'reason' => $reason,
'field' => $payload->field,
'value' => $payload->value,
'timestamp' => now(),
];

return true;
}

/**
* Get all skipped payloads.
* @return array
*/
public function skipped(?string $field = null): array
{
if ($field === null) {
return $this->skipped;
}

return array_values(array_filter(
$this->skipped,
fn($item) => $item['field'] === $field
));
}

/**
* Check if a specific field was skipped.
* @param string $field
* @return bool
*/
public function hasSkipped(string $field): bool
{
return !empty($this->skipped($field));
}

/**
* Get applied payloads.
*
Expand Down
Loading
Loading