Skip to content

Enhancement: Restorable Rich Feature Values #136

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

Draft
wants to merge 4 commits into
base: 1.x
Choose a base branch
from
Draft
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');
});
}
};
10 changes: 10 additions & 0 deletions src/Contracts/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,21 @@ public function get(string $feature, mixed $scope): mixed;
*/
public function set(string $feature, mixed $scope, mixed $value): void;

/**
* Restore a feature flag's value.
*/
public function restore(string $feature, mixed $scope, mixed $value): void;

/**
* Set a feature flag's value for all scopes.
*/
public function setForAllScopes(string $feature, mixed $value): void;

/**
* Restore a feature flag's value for all scopes.
*/
public function restoreForAllScopes(string $feature, mixed $fallback): void;

/**
* Delete a feature flag's value.
*/
Expand Down
24 changes: 24 additions & 0 deletions src/Drivers/ArrayDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,30 @@ public function setForAllScopes($feature, $value): void
}
}

/**
* Restore the value for the given feature and scope.
*
* @param string $feature
* @param mixed $scope
* @param mixed $fallback
* @return void
*/
public function restore($feature, $scope, $fallback = true): void
{
$this->set($feature, $scope, $fallback);
}

/**
* Restore feature flag's value for all scopes.
*
* @param string $feature
* @param mixed $fallback
*/
public function restoreForAllScopes($feature, $fallback): void
{
$this->setForAllScopes($feature, $fallback);
}

/**
* Delete a feature flag's value.
*
Expand Down
141 changes: 135 additions & 6 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,22 @@ 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 value.
*
Expand All @@ -204,7 +220,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 @@ -270,15 +286,41 @@ protected function resolveValue($feature, $scope)
* @param mixed $scope
* @param mixed $value
*/
public function set($feature, $scope, $value): void
public function set($feature, $scope, $value = null): 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;
}

if (is_null($value)) {
$this->newQuery()->upsert([
'name' => $feature,
'scope' => Feature::serializeScope($scope),
'active' => true,
static::CREATED_AT => $now = Carbon::now(),
static::UPDATED_AT => $now,
], uniqueBy: ['name', 'scope'], update: ['active', 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,14 +331,96 @@ public function set($feature, $scope, $value): void
*/
public function setForAllScopes($feature, $value): void
{
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([
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
'active' => false,
static::UPDATED_AT => Carbon::now(),
]);
}

/**
* Restore the value for the given feature and scope.
*
* @param string $feature
* @param mixed $scope
* @param mixed $fallback
* @return void
*/
public function restore($feature, $scope, $fallback = true): void
{
if (($record = $this->retrieve($feature, $scope)) === null) {
return;
}

$values = [
'active' => true,
static::UPDATED_AT => Carbon::now(),
];

if (! json_decode($record->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR)) {
$values['value'] = json_encode($fallback, flags: JSON_THROW_ON_ERROR);
}

$this->newQuery()
->where('name', $feature)
->where('scope', Feature::serializeScope($scope))
->update($values);
}

/**
* Restore a feature flag's values for all scopes.
*
* @param string $feature
* @param mixed $fallback
* @return void
*/
public function restoreForAllScopes($feature, $fallback = true): void
{
$this->connection()->transaction(function () use ($feature, $fallback) {
$this->newQuery()
->where('name', $feature)
->update([
'active' => true,
static::UPDATED_AT => Carbon::now(),
]);

$this->newQuery()
->where('name', $feature)
->where(function ($query) {
$query->where('value', json_encode(false, flags: JSON_THROW_ON_ERROR))
->orWhereNull('value');
})
->update([
'value' => json_encode($fallback, flags: JSON_THROW_ON_ERROR),
static::UPDATED_AT => Carbon::now(),
]);
});
}

/**
* Update the value for the given feature and scope in storage.
*
Expand All @@ -310,9 +434,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 +475,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
58 changes: 58 additions & 0 deletions src/Drivers/Decorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use Laravel\Pennant\Events\DynamicallyRegisteringFeatureClass;
use Laravel\Pennant\Events\FeatureDeleted;
use Laravel\Pennant\Events\FeatureResolved;
use Laravel\Pennant\Events\FeatureRestored;
use Laravel\Pennant\Events\FeatureRestoredForAllScopes;
use Laravel\Pennant\Events\FeatureRetrieved;
use Laravel\Pennant\Events\FeaturesPurged;
use Laravel\Pennant\Events\FeatureUpdated;
Expand Down Expand Up @@ -485,6 +487,28 @@ public function set($feature, $scope, $value): void
Event::dispatch(new FeatureUpdated($feature, $scope, $value));
}

/**
* Restore a feature flag's value.
*
* @internal
*
* @param string $feature
* @param mixed $scope
* @param mixed $fallback
*/
public function restore($feature, $scope, $fallback = true): void
{
$feature = $this->resolveFeature($feature);

$scope = $this->resolveScope($scope);

$this->driver->restore($feature, $scope, $fallback);

$this->putInCache($feature, $scope, $this->driver->get($feature, $scope));

Event::dispatch(new FeatureRestored($feature, $scope, $fallback));
}

/**
* Activate the feature for everyone.
*
Expand All @@ -498,6 +522,19 @@ public function activateForEveryone($feature, $value = true)
->each(fn ($name) => $this->setForAllScopes($name, $value));
}

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

/**
* Deactivate the feature for everyone.
*
Expand Down Expand Up @@ -531,6 +568,27 @@ public function setForAllScopes($feature, $value): void
Event::dispatch(new FeatureUpdatedForAllScopes($feature, $value));
}

/**
* Restore a feature flag's values for all scopes.
*
* @internal
*
* @param string $feature
* @param mixed $fallback
*/
public function restoreForAllScopes($feature, $fallback): void
{
$feature = $this->resolveFeature($feature);

$this->driver->restoreForAllScopes($feature, $fallback);

$this->cache = $this->cache->reject(
fn ($item) => $item['feature'] === $feature
);

Event::dispatch(new FeatureRestoredForAllScopes($feature, $fallback));
}

/**
* Delete a feature flag's value.
*
Expand Down
Loading