diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index fb7d43f6..aa9e0a42 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -147,6 +147,19 @@ public function getContext(): Filterable return $this->context; } + /** + * Clone the engine and bind it to another Filterable instance. + * + * @internal + */ + final public function cloneForContext(Filterable $context): static + { + $engine = clone $this; + $engine->context = $context; + + return $engine; + } + public function getResources(): Resources { return $this->context->getResources(); diff --git a/src/Filterable.php b/src/Filterable.php index 5c2d49f3..deba6e25 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -22,6 +22,7 @@ use Kettasoft\Filterable\Foundation\Contracts\Sortable; use Kettasoft\Filterable\Foundation\FilterableSettings; use Kettasoft\Filterable\Exceptions\MissingBuilderException; +use Kettasoft\Filterable\Foundation\Runtime\Context; use Kettasoft\Filterable\Foundation\Traits\HandleFluentReturn; use Kettasoft\Filterable\Engines\Foundation\Executors\Executer; use Kettasoft\Filterable\Foundation\Contracts\FilterableProfile; @@ -83,10 +84,18 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit protected $requestSource = 'query'; /** - * The Builder instance. - * @var \Illuminate\Database\Eloquent\Builder + * Runtime context for this filterable instance. + * + * Encapsulates all transient state that changes during filter execution: + * - Applied payloads + * - Skipped payloads + * - Parsed request data + * - Query builder instance + * - Cache key generator + * + * @var Context */ - protected Builder $builder; + protected Context $context; /** * Registered sanitizers to operate upon. @@ -94,12 +103,6 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit */ protected $sanitizers = []; - /** - * All received data from request. - * @var array - */ - protected $data = []; - /** * Specify which fields are allowed to be filtered. * @var array @@ -159,20 +162,9 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit */ protected static EventManager $eventManager; - /** - * Applied payloads. - * @var array - */ - protected $applied = []; - - /** - * Skipped payloads. - * @var array - */ - protected array $skipped = []; - /** * Create a new Filterable instance. + * * @param Request|null $request */ public function __construct(Request|null $request = null) @@ -182,6 +174,15 @@ public function __construct(Request|null $request = null) $this->booted(); } + /** + * Keep runtime state isolated between cloned filter instances. + */ + public function __clone(): void + { + $this->context = clone $this->context; + $this->engine = $this->engine->cloneForContext($this); + } + /** * Initialize core dependencies and fire the initializing event. * @@ -191,6 +192,7 @@ public function boot($request = null) { $this->request = $request ?: App::make(Request::class); $this->registerEventManager(); + $this->context = new Context(); // Fire initializing event $this->fireEvent('filterable.initializing', ['filterable' => $this]); @@ -219,7 +221,7 @@ public function booted() // Fire resolved event after initialization is complete $this->fireEvent('filterable.resolved', [ 'engine' => $this->engine, - 'data' => $this->data, + 'data' => $this->context->getData(), ]); } @@ -283,70 +285,63 @@ public function useProfile(FilterableProfile|callable|string $profile): static */ public function commit(string $key, Payload $payload): bool { - $this->applied[$key] = clone $payload; + $this->context->commitPayload($key, $payload); return true; } /** * Register a skipped payload. - * @param Payload $payload - * @param string|null $reason Optional reason for skipping - * @return bool + * + * Records information about a filter that was skipped during execution. + * This is a wrapper method that delegates to the runtime state. + * + * @param Payload $payload The payload that was skipped + * @param string|null $reason Optional explanation for why it was skipped + * @return bool Always returns true to indicate the skip was recorded */ 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(), - ]; - + $this->context->skipPayload($payload, $reason); return true; } /** * Get all skipped payloads. - * @return array + * + * Retrieves information about filters that were skipped, optionally filtered by field. + * This is a wrapper method that delegates to the runtime state. + * + * @param string|null $field Optional field name to filter skipped payloads + * @return array All skipped payloads or filtered by field */ 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 - )); + return $this->context->getSkipped($field); } /** * Check if a specific field was skipped. - * @param string $field - * @return bool + * + * Determines whether any filters for the given field were skipped. + * This is a wrapper method that delegates to the runtime state. + * + * @param string $field The field name to check + * @return bool True if the field has skipped filters, false otherwise */ public function hasSkipped(string $field): bool { - return !empty($this->skipped($field)); + return $this->context->hasSkipped($field); } /** * Get applied payloads. * - * @param string $key + * @param string|null $key * @return array|Payload|null */ public function applied($key = null) { - if (!$key) { - return $this->applied; - } - - return $this->applied[$key] ?? null; + return $this->context->getApplied($key); } /** @@ -399,7 +394,8 @@ public function apply(Builder|null $builder = null): Invoker|Builder $builder = $this->initQueryBuilderInstance($builder); - $this->builder = $this->initially($builder); + $builder = $this->initially($builder); + $this->context->setBuilder($builder); $builder = Executer::execute($this->engine, $builder); @@ -412,11 +408,14 @@ public function apply(Builder|null $builder = null): Invoker|Builder 'filterable' => $this ]); + $builder = $this->finally($builder); + $this->context->setBuilder($builder); + if ($this instanceof ShouldReturnQueryBuilder || $this->shouldReturnQueryBuilder) { - return $this->finally($builder); + return $builder; } - $invoker = new Invoker($this->finally($builder)); + $invoker = new Invoker($builder); // Pass caching settings to invoker if ($this->isCachingEnabled()) { @@ -577,8 +576,8 @@ private function initQueryBuilderInstance(Builder|null $builder = null) if ($builder) return $builder; - if (isset($this->builder)) - return $this->builder; + if ($this->context->hasBuilder()) + return $this->context->getBuilder(); if ($this->model instanceof Model) { return $this->model->query(); @@ -680,7 +679,7 @@ public function through(array $pipes): static throw new \InvalidArgumentException('All pipes passed to `through` must be callable.'); } - $pipe($this->builder, $this); + $pipe($this->getBuilder(), $this); } return $this; @@ -727,13 +726,19 @@ public function getSanitizerInstance(): Sanitizer /** * Set manual data injection. - * @param array $data - * @param bool $override - * @return static + * + * Manually sets filter data, optionally merging with existing data. + * Useful for programmatically applying filters without HTTP request. + * This is a wrapper method that delegates to the runtime state. + * + * @param array $data The filter data to set + * @param bool $override If true, replaces existing data; if false, merges with existing + * @return static Returns $this for method chaining */ public function setData(array $data, bool $override = true): static { - $this->data = $override ? $data : array_merge($this->data, $data); + $currentData = $this->context->getData(); + $this->context->setData($override ? $data : array_merge($currentData, $data)); return $this; } @@ -772,21 +777,30 @@ public function withoutSanitizers(): static } /** - * Parse incomming data from request. + * Parse incoming request data. + * + * Extracts filter parameters from the HTTP request and stores them in runtime state. + * * @return void */ private function parseIncomingRequestData() { - $this->data = [...$this->request->all(), ...$this->request->json()->all()]; + $this->context->setData([...$this->request->all(), ...$this->request->json()->all()]); } /** - * Get current data. - * @return array + * Get current filter data. + * + * Returns the filter parameters extracted from the request. + * If a filter key is set, returns data scoped to that key. + * This is a wrapper method that delegates to the runtime state. + * + * @return mixed The filter data array or scoped data */ public function getData(): mixed { - return $this->filterKey === null ? $this->data : $this->data[$this->filterKey] ?? $this->data; + $data = $this->context->getData(); + return $this->filterKey === null ? $data : ($data[$this->filterKey] ?? $data); } /** @@ -955,21 +969,29 @@ public function setFieldsMap($fields, bool $override = true): static /** * Get registered filter builder. - * @return Builder + * + * Returns the Eloquent query builder that filters are being applied to. + * This is a wrapper method that delegates to the runtime state. + * + * @return Builder The query builder instance */ public function getBuilder(): Builder { - return $this->builder; + return $this->context->getBuilder() ?? throw new MissingBuilderException; } /** * Set a new builder. - * @param Builder $builder - * @return static + * + * Attaches an Eloquent query builder to this filterable instance. + * This is a wrapper method that delegates to the runtime state. + * + * @param Builder $builder The query builder to attach + * @return static Returns $this for method chaining */ public function setBuilder(Builder $builder): static { - $this->builder = $builder; + $this->context->setBuilder($builder); return $this; } @@ -980,7 +1002,7 @@ public function setBuilder(Builder $builder): static */ public function autoSetAllowedFieldsFromModel(bool $override = false): static { - $fillable = $this->builder->getModel()->getFillable(); + $fillable = $this->context->getBuilder()->getModel()->getFillable(); $this->allowedFields = $override ? $fillable : array_merge($this->allowedFields, $fillable); return $this; @@ -994,7 +1016,7 @@ public function autoSetAllowedFieldsFromModel(bool $override = false): static */ public function toSql(Builder|null $builder = null, $withBindings = false): string { - $builder = $this->apply($builder ?? $this->builder); + $builder = $this->apply($builder ?? $this->context->getBuilder()); return $withBindings ? $builder->toRawSql() : $builder->toSql(); } @@ -1029,12 +1051,33 @@ public function getExceptionHandler(): ExceptionHandlerInterface } /** - * Dynamically retrieve attributes from the request. - * @param mixed $property - * @return mixed + * Dynamically retrieve attributes. + * + * Provides backward compatibility for accessing runtime state properties + * (builder, data, applied, skipped) as if they were direct properties. + * + * @param mixed $property The property name + * @return mixed The property value */ public function __get($property): mixed { + // Backward compatibility: map state properties to state object + if ($property === 'builder') { + return $this->context->getBuilder(); + } + + if ($property === 'data') { + return $this->context->getData(); + } + + if ($property === 'applied') { + return $this->context->getApplied(); + } + + if ($property === 'skipped') { + return $this->context->getSkipped(); + } + if (property_exists($this, $property)) { return $this->{$property}; } diff --git a/src/Foundation/Runtime/Context.php b/src/Foundation/Runtime/Context.php new file mode 100644 index 00000000..1575c879 --- /dev/null +++ b/src/Foundation/Runtime/Context.php @@ -0,0 +1,122 @@ + + */ + protected array $applied = []; + + /** + * @var array + */ + protected array $skipped = []; + + protected array $data = []; + + protected ?Builder $builder = null; + + protected ?CacheKeyGenerator $cacheKeyGenerator = null; + + /** + * Store a snapshot of an applied payload. + */ + public function commitPayload(string $key, Payload $payload): void + { + $this->applied[$key] = clone $payload; + } + + /** + * Store a skipped payload and its diagnostic metadata. + */ + public function skipPayload(Payload $payload, ?string $reason = null): void + { + $payload = clone $payload; + + $this->skipped[] = [ + 'payload' => $payload, + 'reason' => $reason, + 'field' => $payload->field, + 'value' => $payload->value, + 'timestamp' => now(), + ]; + } + + /** + * Get all applied payloads or one payload by key. + * + * @return array|Payload|null + */ + public function getApplied(?string $key = null): array|Payload|null + { + if ($key === null) { + return $this->applied; + } + + return $this->applied[$key] ?? null; + } + + /** + * Get all skipped payloads or entries for one field. + */ + public function getSkipped(?string $field = null): array + { + if ($field === null) { + return $this->skipped; + } + + return array_values(array_filter( + $this->skipped, + fn($item) => $item['field'] === $field + )); + } + + public function hasSkipped(string $field): bool + { + return $this->getSkipped($field) !== []; + } + + public function setData(array $data): void + { + $this->data = $data; + } + + public function getData(): array + { + return $this->data; + } + + public function setBuilder(Builder $builder): void + { + $this->builder = $builder; + } + + public function getBuilder(): ?Builder + { + return $this->builder; + } + + public function hasBuilder(): bool + { + return $this->builder !== null; + } + + public function setCacheKeyGenerator(CacheKeyGenerator $generator): void + { + $this->cacheKeyGenerator = $generator; + } + + public function getCacheKeyGenerator(): ?CacheKeyGenerator + { + return $this->cacheKeyGenerator; + } +} diff --git a/src/Foundation/Traits/HandleFluentReturn.php b/src/Foundation/Traits/HandleFluentReturn.php index 08f21196..5f6692cb 100644 --- a/src/Foundation/Traits/HandleFluentReturn.php +++ b/src/Foundation/Traits/HandleFluentReturn.php @@ -18,10 +18,19 @@ trait HandleFluentReturn protected function handleFluentReturn($method, $args) { - $result = $this->forwardCallTo($this->builder, $method, $args); + $builder = method_exists($this, 'getBuilder') + ? $this->getBuilder() + : $this->builder; + + $result = $this->forwardCallTo($builder, $method, $args); if ($result instanceof QueryBuilderInterface) { - $this->builder = $result; + if (method_exists($this, 'setBuilder')) { + $this->setBuilder($result); + } else { + $this->builder = $result; + } + return $this; } diff --git a/src/Traits/HasFilterableCache.php b/src/Traits/HasFilterableCache.php index 96da43bb..44ebadf6 100644 --- a/src/Traits/HasFilterableCache.php +++ b/src/Traits/HasFilterableCache.php @@ -66,13 +66,6 @@ trait HasFilterableCache */ protected $cacheWhenCallback = null; - /** - * Cache key generator instance - * - * @var CacheKeyGenerator|null - */ - protected ?CacheKeyGenerator $cacheKeyGenerator = null; - /** * Enable caching with optional TTL * @@ -321,29 +314,32 @@ protected function generateCacheKey(): string $generator = $this->getCacheKeyGenerator(); $filters = method_exists($this, 'getFilters') ? $this->getFilters() : []; - $providedData = property_exists($this, 'data') ? $this->data : []; + $providedData = $this->context->getData(); return $generator->generate( static::class, $filters, $providedData, $this->cacheScopes, - property_exists($this, 'builder') ? $this->builder : null + $this->context->getBuilder() ); } /** - * Get cache key generator instance + * Get or create cache key generator * * @return CacheKeyGenerator */ protected function getCacheKeyGenerator(): CacheKeyGenerator { - if ($this->cacheKeyGenerator === null) { - $this->cacheKeyGenerator = new CacheKeyGenerator(); + $generator = $this->context->getCacheKeyGenerator(); + + if ($generator === null) { + $generator = new CacheKeyGenerator(); + $this->context->setCacheKeyGenerator($generator); } - return $this->cacheKeyGenerator; + return $generator; } /** diff --git a/src/Traits/InteractsWithValidation.php b/src/Traits/InteractsWithValidation.php index 84e12b93..1d4bf400 100644 --- a/src/Traits/InteractsWithValidation.php +++ b/src/Traits/InteractsWithValidation.php @@ -18,7 +18,10 @@ public function validate(): void return; } - $validator = validator(Arr::only($this->data, array_keys($this->rules())), $this->rules()); + $validator = validator( + Arr::only($this->context->getData(), array_keys($this->rules())), + $this->rules() + ); if ($validator->fails()) { throw new ValidationException($validator); diff --git a/tests/Unit/Foundation/Runtime/ContextTest.php b/tests/Unit/Foundation/Runtime/ContextTest.php new file mode 100644 index 00000000..38b1fc76 --- /dev/null +++ b/tests/Unit/Foundation/Runtime/ContextTest.php @@ -0,0 +1,159 @@ +assertFalse($context->hasBuilder()); + $this->assertNull($context->getBuilder()); + $this->assertNull($context->getCacheKeyGenerator()); + + $context->setData(['status' => 'active']); + $context->setBuilder($builder); + $context->setCacheKeyGenerator($generator); + + $this->assertSame(['status' => 'active'], $context->getData()); + $this->assertSame($builder, $context->getBuilder()); + $this->assertTrue($context->hasBuilder()); + $this->assertSame($generator, $context->getCacheKeyGenerator()); + } + + public function test_it_stores_payload_snapshots() + { + $context = new Context; + $applied = Payload::create('status', '=', 'active', 'active'); + $skipped = Payload::create('title', '=', 'invalid', 'invalid'); + + $context->commitPayload('status', $applied); + $context->skipPayload($skipped, 'Invalid title'); + + $applied->setValue('changed'); + $skipped->setValue('changed'); + + $this->assertSame('active', $context->getApplied('status')->value); + $this->assertSame('invalid', $context->getSkipped('title')[0]['payload']->value); + $this->assertTrue($context->hasSkipped('title')); + $this->assertFalse($context->hasSkipped('missing')); + } + + public function test_cloned_filters_have_isolated_runtime_state_and_engines() + { + $original = new Filterable; + $original->setData(['status' => 'original']); + + $clone = clone $original; + $clone->setData(['status' => 'clone']); + $clone->skip( + Payload::create('status', '=', 'invalid', 'invalid'), + 'Invalid status' + ); + + $this->assertSame(['status' => 'original'], $original->getData()); + $this->assertSame(['status' => 'clone'], $clone->getData()); + $this->assertFalse($original->hasSkipped('status')); + $this->assertTrue($clone->hasSkipped('status')); + $this->assertSame($original, $original->getEngine()->getContext()); + $this->assertSame($clone, $clone->getEngine()->getContext()); + $this->assertNotSame($original->getEngine(), $clone->getEngine()); + } + + public function test_filterable_throws_a_domain_exception_when_builder_is_missing() + { + $this->expectException(MissingBuilderException::class); + + (new Filterable)->getBuilder(); + } + + public function test_builder_context_tracks_initial_and_final_builder_replacements() + { + $source = Post::query(); + + $filter = new class extends Filterable { + public ?Builder $initialBuilder = null; + + public ?Builder $finalBuilder = null; + + protected function initially(Builder $builder): Builder + { + return $this->initialBuilder = clone $builder; + } + + protected function finally(Builder $builder): Builder + { + return $this->finalBuilder = clone $builder; + } + }; + + $result = $filter->shouldReturnQueryBuilder()->apply($source); + + $this->assertNotSame($source, $filter->initialBuilder); + $this->assertNotSame($filter->initialBuilder, $filter->finalBuilder); + $this->assertSame($filter->finalBuilder, $result); + $this->assertSame($result, $filter->getBuilder()); + } + + public function test_validation_uses_complete_runtime_data_when_a_filter_key_is_set() + { + request()->merge([ + 'filter' => ['status' => 'active'], + 'token' => 'present', + ]); + + $filter = new class extends Filterable { + protected $filters = []; + + public function rules(): array + { + return ['token' => ['required']]; + } + }; + + $filter->shouldReturnQueryBuilder()->apply(Post::query()); + + $this->assertSame(['status' => 'active'], $filter->getData()); + } + + public function test_cache_keys_include_complete_runtime_data() + { + $makeFilter = function (int $tenantId) { + return new class($tenantId) extends Filterable { + protected $filters = []; + + public function __construct(int $tenantId) + { + parent::__construct(); + $this->setData([ + 'filter' => ['status' => 'active'], + 'tenant_id' => $tenantId, + ]); + $this->setBuilder(Post::query()); + } + + public function exposedCacheKey(): string + { + return $this->generateCacheKey(); + } + }; + }; + + $this->assertNotSame( + $makeFilter(1)->exposedCacheKey(), + $makeFilter(2)->exposedCacheKey() + ); + } +}