diff --git a/.gitignore b/.gitignore index 800ab77..efa0ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /composer.lock /phpunit.xml /.phpunit.cache +.idea diff --git a/src/Contracts/Driver.php b/src/Contracts/Driver.php index f0592c2..1b43d43 100644 --- a/src/Contracts/Driver.php +++ b/src/Contracts/Driver.php @@ -36,6 +36,13 @@ public function get(string $feature, mixed $scope): mixed; */ public function set(string $feature, mixed $scope, mixed $value): void; + /** + * Set multiple feature flag values. + * + * @param array> $features + */ + public function setAll(array $features): void; + /** * Set a feature flag's value for all scopes. */ diff --git a/src/Drivers/ArrayDriver.php b/src/Drivers/ArrayDriver.php index 2d7c4b7..a8fdc95 100644 --- a/src/Drivers/ArrayDriver.php +++ b/src/Drivers/ArrayDriver.php @@ -158,6 +158,18 @@ public function set($feature, $scope, $value): void $this->resolvedFeatureStates[$feature][Feature::serializeScope($scope)] = $value; } + /** + * Set multiple feature flag values. + * + * @param array> $features + */ + public function setAll($features): void + { + foreach ($features as $featureData) { + $this->set($featureData['feature'], $featureData['scope'], $featureData['value']); + } + } + /** * Set a feature flag's value for all scopes. * diff --git a/src/Drivers/DatabaseDriver.php b/src/Drivers/DatabaseDriver.php index dca938a..7a5ad76 100644 --- a/src/Drivers/DatabaseDriver.php +++ b/src/Drivers/DatabaseDriver.php @@ -281,6 +281,32 @@ public function set($feature, $scope, $value): void ], uniqueBy: ['name', 'scope'], update: ['value', static::UPDATED_AT]); } + /** + * Set multiple feature flag values. + * + * @param array> $features + */ + public function setAll(array $features): void + { + $now = Carbon::now(); + + $this->newQuery()->upsert([ + array_map( + static fn (array $feature) => array_merge( + $feature, + [ + 'name' => $feature, + 'scope' => Feature::serializeScope($feature['scope']), + 'value' => json_encode($feature['value'], flags: JSON_THROW_ON_ERROR), + static::CREATED_AT => $now, + static::UPDATED_AT => $now, + ] + ), + $features + ) + ], uniqueBy: ['name', 'scope'], update: ['value', static::UPDATED_AT]); + } + /** * Set a feature flag's value for all scopes. * diff --git a/src/Drivers/Decorator.php b/src/Drivers/Decorator.php index 72c6db7..2d3d046 100644 --- a/src/Drivers/Decorator.php +++ b/src/Drivers/Decorator.php @@ -487,6 +487,34 @@ public function set($feature, $scope, $value): void Event::dispatch(new FeatureUpdated($feature, $scope, $value)); } + /** + * Set multiple feature flag values. + * + * @internal + * + * @param array> $features + */ + public function setAll(array $features): void + { + $features = array_map(fn ($feature) => [ + 'feature' => $this->resolveFeature($feature['feature']), + 'scope' => $this->resolveScope($feature['scope']), + 'value' => $feature['value'], + ], $features); + + $this->driver->setAll($features); + + foreach ($features as $featureData) { + $this->putInCache($featureData['feature'], $featureData['scope'], $featureData['value']); + + Event::dispatch(new FeatureUpdated( + $featureData['feature'], + $featureData['scope'], + $featureData['value'], + )); + } + } + /** * Activate the feature for everyone. * diff --git a/src/PendingScopedFeatureInteraction.php b/src/PendingScopedFeatureInteraction.php index 654bdeb..990375a 100644 --- a/src/PendingScopedFeatureInteraction.php +++ b/src/PendingScopedFeatureInteraction.php @@ -249,9 +249,12 @@ public function unless($feature, $whenInactive, $whenActive = null) */ public function activate($feature, $value = true) { - Collection::wrap($feature) + $features = Collection::wrap($feature) ->crossJoin($this->scope()) - ->each(fn ($bits) => $this->driver->set($bits[0], $bits[1], $value)); + ->map(fn ($bits) => ['feature' => $bits[0], 'scope' => $bits[1], 'value' => $value]) + ->all(); + + $this->driver->setAll($features); } /** @@ -262,9 +265,12 @@ public function activate($feature, $value = true) */ public function deactivate($feature) { - Collection::wrap($feature) + $features = Collection::wrap($feature) ->crossJoin($this->scope()) - ->each(fn ($bits) => $this->driver->set($bits[0], $bits[1], false)); + ->map(fn ($bits) => ['feature' => $bits[0], 'scope' => $bits[1], 'value' => false]) + ->all(); + + $this->driver->setAll($features); } /** diff --git a/tests/Feature/ArrayDriverTest.php b/tests/Feature/ArrayDriverTest.php index 7de89ac..c67ede9 100644 --- a/tests/Feature/ArrayDriverTest.php +++ b/tests/Feature/ArrayDriverTest.php @@ -1268,6 +1268,96 @@ public function test_it_can_handles_float_scopes_correctly() FloatScopeFeature::class => false, ], Feature::for(10.00)->all()); } + + public function test_it_dispatches_events_when_activating_multiple_features_and_scopes() + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => true, + ], $events); + } + + public function test_it_dispatches_events_when_deactivating_multiple_features_and_scopes() + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->deactivate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => false, + ], $events); + } } class MyFeature diff --git a/tests/Feature/DatabaseDriverTest.php b/tests/Feature/DatabaseDriverTest.php index d020640..b507243 100644 --- a/tests/Feature/DatabaseDriverTest.php +++ b/tests/Feature/DatabaseDriverTest.php @@ -1743,6 +1743,108 @@ public function test_can_retrieve_scalar_values_without_in_memory_cache(): void $this->assertEquals($expectedValue, $retrieved); } } + + public function test_it_dispatches_events_when_activating_multiple_features_and_scopes() + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => true, + ], $events); + } + + public function test_it_dispatches_events_when_deactivating_multiple_features_and_scopes() + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->deactivate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => false, + ], $events); + } + + public function test_it_uses_single_query_when_activating_multiple_features_and_scopes() + { + DB::enableQueryLog(); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertCount(1, DB::getQueryLog()); + } } class UnregisteredFeature