Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Feature values to be restored #135

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Pennant\Migrations\PennantMigration;

return new class extends PennantMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('features', function (Blueprint $table) {
$table->boolean('active')->after('value')->nullable();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('features', function (Blueprint $table) {
$table->dropColumn('active');
});
}
};
7 changes: 7 additions & 0 deletions src/Contracts/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public function defined(): array;
*/
public function getAll(array $features): array;

/**
* Retrieve a feature flag's raw value, regardless of activation.
*
* @return mixed
*/
public function getRaw(string $feature, mixed $scope);

/**
* Retrieve a feature flag's value.
*/
Expand Down
15 changes: 15 additions & 0 deletions src/Drivers/ArrayDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
use Laravel\Pennant\Contracts\HasFlushableCache;
use Laravel\Pennant\Events\UnknownFeatureResolved;
use Laravel\Pennant\Feature;
use RuntimeException;
use stdClass;
use UnexpectedValueException;

class ArrayDriver implements CanListStoredFeatures, Driver, HasFlushableCache
{
Expand Down Expand Up @@ -55,6 +57,19 @@ public function __construct(Dispatcher $events, $featureStateResolvers)
$this->unknownFeatureValue = new stdClass;
}

/**
* Retrieve a feature flag's raw value.
*
* @param string $feature
* @param mixed $scope
*
* @return mixed
*/
public function getRaw($feature, $scope)
{
throw new RuntimeException('This pennant driver does not support getting raw values.');
}

/**
* Define an initial feature flag state resolver.
*
Expand Down
105 changes: 100 additions & 5 deletions src/Drivers/DatabaseDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public function getAll($features): array
$filtered = $records->where('name', $feature)->where('scope', Feature::serializeScope($scope));

if ($filtered->isNotEmpty()) {
return json_decode($filtered->value('value'), flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); // @phpstan-ignore argument.type
return $this->value($filtered->first());
}

return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scope, $inserts) {
Expand Down Expand Up @@ -195,6 +195,59 @@ public function getAll($features): array
return $results;
}

/**
* Get the feature record's value
*
* @param object $record
*/
protected function value(object $record): mixed
{
$value = json_decode($record->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR);

if (isset($record->active)) {
return (bool) $record->active ? $value : false;
}

return $value;
}

/**
* Retrieve a feature flag's raw value.
*
* @param string $feature
* @param mixed $scope
*
* @return mixed
*/
public function getRaw($feature, $scope)
{
if (($record = $this->retrieve($feature, $scope)) !== null) {
return json_decode($record->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR);
}

return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scope) {
if ($value === $this->unknownFeatureValue) {
return false;
}

try {
$this->insert($feature, $scope, $value);
} catch (UniqueConstraintViolationException $e) {
if ($this->retryDepth === 1) {
throw new RuntimeException('Unable to insert feature value into the database.', previous: $e);
}

$this->retryDepth++;

return $this->getRaw($feature, $scope);
} finally {
$this->retryDepth = 0;
}

return $value;
});
}

/**
* Retrieve a feature flag's value.
*
Expand All @@ -204,7 +257,7 @@ public function getAll($features): array
public function get($feature, $scope): mixed
{
if (($record = $this->retrieve($feature, $scope)) !== null) {
return json_decode($record->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR);
return $this->value($record);
}

return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scope) {
Expand Down Expand Up @@ -272,13 +325,27 @@ protected function resolveValue($feature, $scope)
*/
public function set($feature, $scope, $value): void
{
if ($value) {
$this->newQuery()->upsert([
'name' => $feature,
'scope' => Feature::serializeScope($scope),
'active' => true,
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::CREATED_AT => $now = Carbon::now(),
static::UPDATED_AT => $now,
], uniqueBy: ['name', 'scope'], update: ['active', 'value', static::UPDATED_AT]);

return;
}

$this->newQuery()->upsert([
'name' => $feature,
'scope' => Feature::serializeScope($scope),
'active' => false,
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::CREATED_AT => $now = Carbon::now(),
static::UPDATED_AT => $now,
], uniqueBy: ['name', 'scope'], update: ['value', static::UPDATED_AT]);
], uniqueBy: ['name', 'scope'], update: ['active', static::UPDATED_AT]);
}

/**
Expand All @@ -289,12 +356,35 @@ public function set($feature, $scope, $value): void
*/
public function setForAllScopes($feature, $value): void
{
$this->newQuery()
if ($value) {
$this->newQuery()
->where('name', $feature)
->update([
'active' => true,
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::UPDATED_AT => Carbon::now(),
]);

return;
}

if (is_null($value)) {
$this->newQuery()
->where('name', $feature)
->update([
'active' => true,
static::UPDATED_AT => Carbon::now(),
]);

return;
}

$this->newQuery()
->where('name', $feature)
->update([
'active' => false,
static::UPDATED_AT => Carbon::now(),
]);
}

/**
Expand All @@ -310,9 +400,13 @@ protected function update($feature, $scope, $value)
return (bool) $this->newQuery()
->where('name', $feature)
->where('scope', Feature::serializeScope($scope))
->update([
->update($value ? [
'active' => true,
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::UPDATED_AT => Carbon::now(),
]:[
'active' => false,
static::UPDATED_AT => Carbon::now(),
]);
}

Expand Down Expand Up @@ -347,6 +441,7 @@ protected function insertMany($inserts)
'name' => $insert['name'],
'scope' => Feature::serializeScope($insert['scope']),
'value' => json_encode($insert['value'], flags: JSON_THROW_ON_ERROR),
'active' => $insert['value'] ? true : false,
static::CREATED_AT => $now,
static::UPDATED_AT => $now,
], $inserts));
Expand Down
25 changes: 25 additions & 0 deletions src/Drivers/Decorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,19 @@ protected function normalizeFeaturesToLoad($features)
);
}

/**
* Retrieve a feature flag's raw value.
*
* @internal
*
* @param string $feature
* @param mixed $scope
*/
public function getRaw($feature, $scope): mixed
{
return $this->driver->getRaw($feature, $scope);
}

/**
* Retrieve a feature flag's value.
*
Expand Down Expand Up @@ -485,6 +498,18 @@ public function set($feature, $scope, $value): void
Event::dispatch(new FeatureUpdated($feature, $scope, $value));
}

/**
* Restore the feature for everyone.
*
* @param string|array<string> $feature
* @return void
*/
public function restoreForEveryone($feature)
{
Collection::wrap($feature)
->each(fn ($name) => $this->setForAllScopes($name, null));
}

/**
* Activate the feature for everyone.
*
Expand Down
14 changes: 14 additions & 0 deletions src/PendingScopedFeatureInteraction.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,20 @@ public function unless($feature, $whenInactive, $whenActive = null)
return $this->when($feature, $whenActive ?? fn () => null, $whenInactive);
}

/**
* Restore the feature.
*
* @param string|array<string> $feature
* @param mixed $fallback
* @return void
*/
public function restore($feature, $fallback = true)
{
Collection::wrap($feature)
->crossJoin($this->scope())
->each(fn ($bits) => $this->driver->set($bits[0], $bits[1], $this->driver->getRaw($feature, $this->scope()[0]) ?: $fallback));
}

/**
* Activate the feature.
*
Expand Down
70 changes: 70 additions & 0 deletions tests/Feature/DatabaseDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,76 @@ public function test_it_defaults_to_false_for_unknown_values()
$this->assertCount(1, DB::getQueryLog());
}

public function test_it_can_restore_a_rich_feature_value_with_a_fallback()
{
Feature::define('foo', fn () => false);

$this->assertFalse(Feature::for('tim')->value('foo'));
$this->assertFalse(Feature::for('tim')->active('foo'));
$this->assertFalse(Feature::getDriver()->get('foo', 'tim'));
$this->assertFalse(Feature::getDriver()->getRaw('foo', 'tim'));

Feature::for('tim')->restore('foo', fallback: 'bar');

$this->assertEquals(Feature::for('tim')->value('foo'), 'bar');
$this->assertTrue(Feature::for('tim')->active('foo'));
$this->assertEquals(Feature::getDriver()->get('foo', 'tim'), 'bar');
$this->assertEquals(Feature::getDriver()->getRaw('foo', 'tim'), 'bar');
}

public function test_it_can_restore_a_rich_feature_value()
{
Feature::define('foo', fn () => false);

Feature::for('tim')->activate('foo', 'bar');

$this->assertEquals(Feature::for('tim')->value('foo'), 'bar');
$this->assertTrue(Feature::for('tim')->active('foo'));
$this->assertEquals(Feature::getDriver()->get('foo', 'tim'), 'bar');
$this->assertEquals(Feature::getDriver()->getRaw('foo', 'tim'), 'bar');

Feature::for('tim')->deactivate('foo');

$this->assertFalse(Feature::for('tim')->value('foo'));
$this->assertFalse(Feature::for('tim')->active('foo'));
$this->assertFalse(Feature::getDriver()->get('foo', 'tim'));
$this->assertEquals(Feature::getDriver()->getRaw('foo', 'tim'), 'bar');

Feature::for('tim')->restore('foo');

$this->assertEquals(Feature::for('tim')->value('foo'), 'bar');
$this->assertTrue(Feature::for('tim')->active('foo'));
$this->assertEquals(Feature::getDriver()->get('foo', 'tim'), 'bar');
$this->assertEquals(Feature::getDriver()->getRaw('foo', 'tim'), 'bar');
}

public function test_it_can_restore_a_rich_feature_value_for_everyone()
{
Feature::define('foo', fn () => false);

Feature::for('tim')->activate('foo', 'bar');
Feature::for('taylor')->activate('foo', 'bar');

$this->assertEquals(Feature::for('tim')->value('foo'), 'bar');
$this->assertEquals(Feature::for('taylor')->value('foo'), 'bar');
$this->assertEquals(Feature::getDriver()->get('foo', 'tim'), 'bar');
$this->assertEquals(Feature::getDriver()->get('foo', 'taylor'), 'bar');

Feature::deactivateForEveryone('foo');

$this->assertFalse(Feature::for('tim')->value('foo'));
$this->assertFalse(Feature::for('taylor')->value('foo'));
$this->assertFalse(Feature::getDriver()->get('foo', 'tim'));
$this->assertFalse(Feature::getDriver()->get('foo', 'taylor'));

Feature::restoreForEveryone('foo');

$this->assertEquals(Feature::for('tim')->value('foo'), 'bar');
$this->assertEquals(Feature::for('taylor')->value('foo'), 'bar');
$this->assertEquals(Feature::getDriver()->get('foo', 'tim'), 'bar');
$this->assertEquals(Feature::getDriver()->get('foo', 'taylor'), 'bar');
}

public function test_it_dispatches_events_on_unknown_feature_checks()
{
Event::fake([UnknownFeatureResolved::class]);
Expand Down