diff --git a/src/Contracts/CanSetManyFeaturesForScopes.php b/src/Contracts/CanSetManyFeaturesForScopes.php new file mode 100644 index 0000000..0a1bcb2 --- /dev/null +++ b/src/Contracts/CanSetManyFeaturesForScopes.php @@ -0,0 +1,13 @@ + $features + */ + public function setAll(array $features): void; +} diff --git a/src/Drivers/ArrayDriver.php b/src/Drivers/ArrayDriver.php index 2d7c4b7..1ed369b 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 list $features + */ + public function setAll(array $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..c18c669 100644 --- a/src/Drivers/DatabaseDriver.php +++ b/src/Drivers/DatabaseDriver.php @@ -10,13 +10,14 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Laravel\Pennant\Contracts\CanListStoredFeatures; +use Laravel\Pennant\Contracts\CanSetManyFeaturesForScopes; use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Events\UnknownFeatureResolved; use Laravel\Pennant\Feature; use RuntimeException; use stdClass; -class DatabaseDriver implements CanListStoredFeatures, Driver +class DatabaseDriver implements CanListStoredFeatures, CanSetManyFeaturesForScopes, Driver { /** * The database connection. @@ -281,6 +282,24 @@ public function set($feature, $scope, $value): void ], uniqueBy: ['name', 'scope'], update: ['value', static::UPDATED_AT]); } + /** + * Set multiple feature flag values. + * + * @param list $features + */ + public function setAll(array $features): void + { + $now = Carbon::now(); + + $this->newQuery()->upsert(array_map(fn (array $feature) => [ + 'name' => $feature['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..daf0ad0 100644 --- a/src/Drivers/Decorator.php +++ b/src/Drivers/Decorator.php @@ -10,6 +10,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Laravel\Pennant\Contracts\CanListStoredFeatures; +use Laravel\Pennant\Contracts\CanSetManyFeaturesForScopes; use Laravel\Pennant\Contracts\DefinesFeaturesExternally; use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Contracts\FeatureScopeable; @@ -37,7 +38,7 @@ /** * @mixin \Laravel\Pennant\PendingScopedFeatureInteraction */ -class Decorator implements CanListStoredFeatures, Driver, HasFlushableCache +class Decorator implements CanListStoredFeatures, CanSetManyFeaturesForScopes, Driver, HasFlushableCache { use Macroable { __call as macroCall; @@ -487,6 +488,44 @@ public function set($feature, $scope, $value): void Event::dispatch(new FeatureUpdated($feature, $scope, $value)); } + /** + * Set multiple feature flag values. + * + * @internal + * + * @param list $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); + + $updated = false; + + if ($this->driver instanceof CanSetManyFeaturesForScopes) { + $this->driver->setAll($features); + + $updated = true; + } + + foreach ($features as $feature) { + if (! $updated) { + $this->driver->set($feature['feature'], $feature['scope'], $feature['value']); + } + + $this->putInCache($feature['feature'], $feature['scope'], $feature['value']); + + Event::dispatch(new FeatureUpdated( + $feature['feature'], + $feature['scope'], + $feature['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..328edb6 100644 --- a/tests/Feature/DatabaseDriverTest.php +++ b/tests/Feature/DatabaseDriverTest.php @@ -209,7 +209,7 @@ public function test_it_can_activate_and_deactivate_several_features_at_once() $this->assertTrue(Feature::active('bar')); $this->assertTrue(Feature::active('bar')); - $this->assertCount(7, DB::getQueryLog()); + $this->assertCount(4, DB::getQueryLog()); } public function test_it_can_check_if_multiple_features_are_active_at_once() @@ -226,7 +226,7 @@ public function test_it_can_check_if_multiple_features_are_active_at_once() $this->assertTrue(Feature::allAreActive(['foo', 'bar'])); $this->assertFalse(Feature::allAreActive(['foo', 'bar', 'baz'])); - $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(3, DB::getQueryLog()); } public function test_it_can_scope_features() @@ -276,7 +276,7 @@ public function test_it_can_activate_and_deactivate_features_for_multiple_scope_ $this->assertTrue(Feature::for($second)->active('foo')); $this->assertFalse(Feature::for($third)->active('foo')); - $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(3, DB::getQueryLog()); } public function test_it_can_activate_and_deactivate_multiple_features_for_multiple_scope_at_once() @@ -297,7 +297,7 @@ public function test_it_can_activate_and_deactivate_multiple_features_for_multip $this->assertTrue(Feature::for($second)->active('bar')); $this->assertFalse(Feature::for($third)->active('bar')); - $this->assertCount(8, DB::getQueryLog()); + $this->assertCount(5, DB::getQueryLog()); } public function test_it_can_check_multiple_features_for_multiple_scope_at_once() @@ -317,7 +317,7 @@ public function test_it_can_check_multiple_features_for_multiple_scope_at_once() $this->assertFalse(Feature::for([$second, $third])->allAreActive(['foo', 'bar'])); $this->assertFalse(Feature::for([$first, $second, $third])->allAreActive(['foo', 'bar'])); - $this->assertCount(6, DB::getQueryLog()); + $this->assertCount(3, DB::getQueryLog()); } public function test_null_is_same_as_global() @@ -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