diff --git a/.gitignore b/.gitignore index 660fc15..800ab77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /vendor -composer.lock +/composer.lock /phpunit.xml -.phpunit.result.cache +/.phpunit.cache diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a30bee0..b5b979b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,19 @@ - + ./tests/Feature - + ./src - + diff --git a/src/Drivers/DatabaseDriver.php b/src/Drivers/DatabaseDriver.php index 95e5615..1291a5b 100644 --- a/src/Drivers/DatabaseDriver.php +++ b/src/Drivers/DatabaseDriver.php @@ -144,7 +144,7 @@ public function getAll($features): array { $query = $this->newQuery(); - $features = Collection::make($features) + $resolved = Collection::make($features) ->map(fn ($scopes, $feature) => Collection::make($scopes) ->each(fn ($scope) => $query->orWhere( fn ($q) => $q->where('name', $feature)->where('scope', Feature::serializeScope($scope)) @@ -154,7 +154,7 @@ public function getAll($features): array $inserts = new Collection; - $results = $features->map(fn ($scopes, $feature) => $scopes->map(function ($scope) use ($feature, $records, $inserts) { + $results = $resolved->map(fn ($scopes, $feature) => $scopes->map(function ($scope) use ($feature, $records, $inserts) { $filtered = $records->where('name', $feature)->where('scope', Feature::serializeScope($scope)); if ($filtered->isNotEmpty()) { diff --git a/src/Drivers/Decorator.php b/src/Drivers/Decorator.php index acb1a38..f9e2f4f 100644 --- a/src/Drivers/Decorator.php +++ b/src/Drivers/Decorator.php @@ -223,12 +223,46 @@ public function getAll($features): array return []; } - return tap($this->driver->getAll($features->all()), function ($results) use ($features) { - $features->flatMap(fn ($scopes, $key) => Collection::make($scopes) - ->zip($results[$key]) - ->map(fn ($scopes) => $scopes->push($key))) - ->each(fn ($value) => $this->putInCache($value[2], $value[0], $value[1])); - }); + $hasUnresolvedFeatures = false; + + $resolvedBefore = $features->reduce(function ($resolved, $scopes, $feature) use (&$hasUnresolvedFeatures) { + $resolved[$feature] = []; + + if (! method_exists($feature, 'before')) { + $hasUnresolvedFeatures = true; + + return $resolved; + } + + $before = $this->container->make($feature)->before(...); + + foreach ($scopes as $index => $scope) { + $value = $this->resolveBeforeHook($feature, $scope, $before); + + if ($value !== null) { + $resolved[$feature][$index] = $value; + } else { + $hasUnresolvedFeatures = true; + } + } + + return $resolved; + }, []); + + $results = array_replace_recursive( + $features->all(), + $resolvedBefore, + $hasUnresolvedFeatures ? $this->driver->getAll($features->map(function ($scopes, $feature) use ($resolvedBefore) { + return array_diff_key($scopes, $resolvedBefore[$feature]); + })->all()) : [], + ); + + $features->flatMap(fn ($scopes, $key) => Collection::make($scopes) + ->zip($results[$key]) + ->map(fn ($scopes) => $scopes->push($key))) + ->each(fn ($value) => $this->putInCache($value[2], $value[0], $value[1])); + + return $results; } /** @@ -294,11 +328,36 @@ public function get($feature, $scope): mixed return $item['value']; } - return tap($this->driver->get($feature, $scope), function ($value) use ($feature, $scope) { - $this->putInCache($feature, $scope, $value); + $before = method_exists($feature, 'before') + ? $this->container->make($feature)->before(...) + : fn () => null; - Event::dispatch(new FeatureRetrieved($feature, $scope, $value)); - }); + $value = $this->resolveBeforeHook($feature, $scope, $before) ?? $this->driver->get($feature, $scope); + + $this->putInCache($feature, $scope, $value); + + Event::dispatch(new FeatureRetrieved($feature, $scope, $value)); + + return $value; + } + + /** + * Resolve the before hook value. + * + * @param string $feature + * @param mixed $scope + * @param callable $hook + * @return mixed + */ + protected function resolveBeforeHook($feature, $scope, $hook) + { + if ($scope === null && ! $this->canHandleNullScope($hook)) { + Event::dispatch(new UnexpectedNullScopeEncountered($feature)); + + return null; + } + + return $hook($scope); } /** diff --git a/tests/Feature/DatabaseDriverTest.php b/tests/Feature/DatabaseDriverTest.php index 6211bb5..487bc0b 100644 --- a/tests/Feature/DatabaseDriverTest.php +++ b/tests/Feature/DatabaseDriverTest.php @@ -21,6 +21,7 @@ use Laravel\Pennant\Events\FeaturesPurged; use Laravel\Pennant\Events\FeatureUpdated; use Laravel\Pennant\Events\FeatureUpdatedForAllScopes; +use Laravel\Pennant\Events\UnexpectedNullScopeEncountered; use Laravel\Pennant\Events\UnknownFeatureResolved; use Laravel\Pennant\Feature; use RuntimeException; @@ -1383,6 +1384,103 @@ public function test_it_only_retries_on_conflicts_for_individual_queries() $this->assertSame(1, $insertAttempts); } + public function test_it_can_intercept_feature_values_using_before_hook() + { + $queries = 0; + FeatureWithBeforeHook::$before = fn ($scope) => 'before'; + Feature::define(FeatureWithBeforeHook::class); + Feature::activate(FeatureWithBeforeHook::class, 'stored-value'); + Feature::flushCache(); + DB::listen(function (QueryExecuted $event) use (&$queries) { + $queries++; + }); + + $value = Feature::for(null)->value(FeatureWithBeforeHook::class); + + $this->assertSame('before', $value); + $this->assertSame(0, $queries); + } + + public function test_it_can_merges_before_with_resolved_features() + { + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + $queries++; + }); + FeatureWithBeforeHook::$before = fn ($scope) => ['before' => 'value']; + Feature::define('foo', 'bar'); + Feature::define(FeatureWithBeforeHook::class); + + $values = Feature::for('user:1')->all(); + + $this->assertSame([ + 'foo' => 'bar', + 'Tests\Feature\FeatureWithBeforeHook' => ['before' => 'value'], + ], $values); + $this->assertSame(2, $queries); + } + + public function test_it_can_get_features_with_before_hook() + { + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + $queries++; + }); + FeatureWithBeforeHook::$before = fn ($scope) => ['before' => 'value']; + + $value = Feature::get(FeatureWithBeforeHook::class, null); + + $this->assertSame(['before' => 'value'], $value); + $this->assertSame(0, $queries); + } + + public function test_it_handles_null_scope_for_before_hook() + { + Event::fake(UnexpectedNullScopeEncountered::class); + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + $queries++; + }); + FeatureWithTypedBeforeHook::$before = fn ($scope) => 'before-value'; + Feature::define(FeatureWithTypedBeforeHook::class); + + $values = Feature::for(null)->value(FeatureWithTypedBeforeHook::class); + + $this->assertSame('feature-value', $values); + $this->assertSame(2, $queries); + + Feature::flushCache(); + + $value = Feature::get(FeatureWithTypedBeforeHook::class, null); + + $this->assertSame('feature-value', $values); + $this->assertSame(3, $queries); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 2); + } + + public function test_it_maintains_scope_feature_keys() + { + $count = 0; + FeatureWithBeforeHook::$before = function ($scope) use (&$count) { + if ($scope === 'user:2') { + return null; + } + + $count++; + + return "before-value-{$count}"; + }; + Feature::define(FeatureWithBeforeHook::class); + + $values = Feature::getAll([ + FeatureWithBeforeHook::class => ['user:1', 'user:2', 'user:3'], + ]); + + $this->assertSame([ + 'Tests\Feature\FeatureWithBeforeHook' => ['before-value-1', 'feature-value', 'before-value-2'], + ], $values); + } + public function test_it_keys_by_feature_name() { Feature::define(FeatureWithName::class); @@ -1467,6 +1565,36 @@ public function __invoke() } } +class FeatureWithBeforeHook +{ + public static $before; + + public function resolve() + { + return 'feature-value'; + } + + public function before() + { + return (static::$before)(...func_get_args()); + } +} + +class FeatureWithTypedBeforeHook +{ + public static $before; + + public function resolve() + { + return 'feature-value'; + } + + public function before(string $scope) + { + return (static::$before)(...func_get_args()); + } +} + class FeatureWithoutName { public function __invoke()