diff --git a/src/Commands/PurgeCommand.php b/src/Commands/PurgeCommand.php index a87f659..ad8eb6b 100644 --- a/src/Commands/PurgeCommand.php +++ b/src/Commands/PurgeCommand.php @@ -14,6 +14,8 @@ class PurgeCommand extends Command */ protected $signature = 'pennant:purge {features?* : The features to purge} + {--except=* : The features that should be excluded from purging} + {--except-registered : Purge all features except those registered} {--store= : The store to purge the features from}'; /** @@ -37,9 +39,27 @@ class PurgeCommand extends Command */ public function handle(FeatureManager $manager) { - $manager->store($this->option('store'))->purge($this->argument('features') ?: null); + $store = $manager->store($this->option('store')); - with($this->argument('features') ?: ['All features'], function ($names) { + $features = $this->argument('features') ?: null; + + $except = collect($this->option('except')) + ->when($this->option('except-registered'), fn ($except) => $except->merge($store->defined())) + ->unique() + ->all(); + + if ($except) { + $features = collect($features ?: $store->stored()) + ->flip() + ->forget($except) + ->flip() + ->values() + ->all(); + } + + $store->purge($features); + + with($features ?: ['All features'], function ($names) { $this->components->info(implode(', ', $names).' successfully purged from storage.'); }); diff --git a/src/Contracts/CanListStoredFeatures.php b/src/Contracts/CanListStoredFeatures.php new file mode 100644 index 0000000..ff6357d --- /dev/null +++ b/src/Contracts/CanListStoredFeatures.php @@ -0,0 +1,13 @@ + + */ + public function stored(): array; +} diff --git a/src/Drivers/ArrayDriver.php b/src/Drivers/ArrayDriver.php index 34e7d6b..ec2fe85 100644 --- a/src/Drivers/ArrayDriver.php +++ b/src/Drivers/ArrayDriver.php @@ -4,12 +4,13 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Collection; +use Laravel\Pennant\Contracts\CanListStoredFeatures; use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Events\UnknownFeatureResolved; use Laravel\Pennant\Feature; use stdClass; -class ArrayDriver implements Driver +class ArrayDriver implements CanListStoredFeatures, Driver { /** * The event dispatcher. @@ -74,6 +75,16 @@ public function defined(): array return array_keys($this->featureStateResolvers); } + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + return array_keys($this->resolvedFeatureStates); + } + /** * Get multiple feature flag values. * diff --git a/src/Drivers/DatabaseDriver.php b/src/Drivers/DatabaseDriver.php index 7c7654b..ba0b151 100644 --- a/src/Drivers/DatabaseDriver.php +++ b/src/Drivers/DatabaseDriver.php @@ -8,12 +8,13 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Laravel\Pennant\Contracts\CanListStoredFeatures; use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Events\UnknownFeatureResolved; use Laravel\Pennant\Feature; use stdClass; -class DatabaseDriver implements Driver +class DatabaseDriver implements CanListStoredFeatures, Driver { /** * The database connection. @@ -86,7 +87,7 @@ public function define($feature, $resolver): void } /** - * Define the names of all defined features. + * Retrieve the names of all defined features. * * @return array */ @@ -95,6 +96,21 @@ public function defined(): array return array_keys($this->featureStateResolvers); } + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + return $this->newQuery() + ->select('name') + ->distinct() + ->get() + ->pluck('name') + ->all(); + } + /** * Get multiple feature flag values. * diff --git a/src/Drivers/Decorator.php b/src/Drivers/Decorator.php index 3cc58b1..a8b490e 100644 --- a/src/Drivers/Decorator.php +++ b/src/Drivers/Decorator.php @@ -9,7 +9,8 @@ use Illuminate\Support\Lottery; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; -use Laravel\Pennant\Contracts\Driver as DriverContract; +use Laravel\Pennant\Contracts\CanListStoredFeatures; +use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Contracts\FeatureScopeable; use Laravel\Pennant\Events\AllFeaturesPurged; use Laravel\Pennant\Events\DynamicallyRegisteringFeatureClass; @@ -24,12 +25,13 @@ use Laravel\Pennant\LazilyResolvedFeature; use Laravel\Pennant\PendingScopedFeatureInteraction; use ReflectionFunction; +use RuntimeException; use Symfony\Component\Finder\Finder; /** * @mixin \Laravel\Pennant\PendingScopedFeatureInteraction */ -class Decorator implements DriverContract +class Decorator implements CanListStoredFeatures, Driver { use Macroable { __call as macroCall; @@ -191,6 +193,20 @@ public function defined(): array return $this->driver->defined(); } + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + if (! $this->driver instanceof CanListStoredFeatures) { + throw new RuntimeException("The [{$this->name}] driver does not support listing stored features."); + } + + return $this->driver->stored(); + } + /** * Get multiple feature flag values. * @@ -391,7 +407,7 @@ public function purge($features = null): void Collection::wrap($features) ->map($this->resolveFeature(...)) ->pipe(function ($features) { - $this->driver->purge($features); + $this->driver->purge($features->all()); $this->cache->forget( $this->cache->whereInStrict('feature', $features)->keys()->all() diff --git a/tests/Feature/ArrayDriverTest.php b/tests/Feature/ArrayDriverTest.php index 80623f3..e46a23d 100644 --- a/tests/Feature/ArrayDriverTest.php +++ b/tests/Feature/ArrayDriverTest.php @@ -1144,6 +1144,16 @@ public function test_caching_of_features(): void $this->assertEquals(4, Feature::for($user1)->value('myflag')); $this->assertEquals(4, Feature::for($user2)->value('myflag')); } + + public function test_it_can_list_stored_features() + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::for('Tim')->active('bar'); + + $this->assertSame(Feature::stored(), ['bar']); + } } class MyFeature diff --git a/tests/Feature/DatabaseDriverTest.php b/tests/Feature/DatabaseDriverTest.php index 4b189a9..43fb016 100644 --- a/tests/Feature/DatabaseDriverTest.php +++ b/tests/Feature/DatabaseDriverTest.php @@ -1276,6 +1276,23 @@ public function test_it_respects_updated_connection_configuration() $this->expectExceptionMessage('Database connection [xxxx] not configured.'); Feature::store('database')->active('feature-name'); } + + public function test_it_can_list_stored_features() + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::for('tim')->active('bar'); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->assertSame(Feature::stored(), ['bar', 'baz']); + } } class UnregisteredFeature diff --git a/tests/Feature/PurgeCommandTest.php b/tests/Feature/PurgeCommandTest.php index 46d11c1..de67cfd 100644 --- a/tests/Feature/PurgeCommandTest.php +++ b/tests/Feature/PurgeCommandTest.php @@ -103,4 +103,92 @@ public function purge() $this->expectExceptionMessage('Pennant store [foo] is not defined.'); $this->artisan('pennant:purge --store=foo'); } + + public function test_it_can_exclude_features_to_purge_from_storage() + { + Feature::define('foo', true); + Feature::define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + + Feature::for('taylor')->active('bar'); + + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->assertCount(3, DB::table('features')->get()->unique('name')); + + $this->artisan('pennant:purge --except=foo')->expectsOutputToContain('bar, baz successfully purged from storage.'); + + $this->assertCount(1, DB::table('features')->get()->unique('name')); + + $this->artisan('pennant:purge foo')->expectsOutputToContain('foo successfully purged from storage.'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function test_it_can_combine_except_and_features_as_arguments() + { + DB::table('features')->insert([ + 'name' => 'foo', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'bar', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->artisan('pennant:purge foo bar --except=bar')->expectsOutputToContain('foo successfully purged from storage.'); + + $this->assertSame(['bar', 'baz'], DB::table('features')->pluck('name')->all()); + } + + public function test_it_can_purge_features_except_those_registered() + { + Feature::define('foo', fn () => true); + DB::table('features')->insert([ + 'name' => 'foo', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'bar', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->artisan('pennant:purge --except-registered')->expectsOutputToContain('bar, baz successfully purged from storage.'); + + $this->assertSame(['foo'], DB::table('features')->pluck('name')->all()); + } }