feat: implement advanced rule engine and targeting
This commit is contained in:
parent
5043031a33
commit
1951ce9c7d
|
|
@ -4,41 +4,99 @@ declare(strict_types=1);
|
|||
|
||||
namespace FlagPole;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
final class Evaluator
|
||||
{
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
}
|
||||
|
||||
public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool
|
||||
{
|
||||
$context ??= new Context();
|
||||
|
||||
// 1) Allow list wins if present
|
||||
if (!empty($flag->allowList)) {
|
||||
$key = $this->resolveTargetingKey($context);
|
||||
$key = $this->resolveTargetingKey($context, $flag->targetingKey);
|
||||
if ($key !== null && in_array((string)$key, $flag->allowList, true)) {
|
||||
$this->logger->info(sprintf('Flag "%s" enabled for key "%s" via allowList.', $flag->name, $key));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Explicit on/off
|
||||
if ($flag->enabled !== null) {
|
||||
$this->logger->info(sprintf('Flag "%s" evaluated to %s via explicit "enabled" override.', $flag->name, $flag->enabled ? 'TRUE' : 'FALSE'));
|
||||
return $flag->enabled;
|
||||
}
|
||||
|
||||
// 3) Percentage rollout if available
|
||||
// 3) Rules matching
|
||||
if (!empty($flag->rules)) {
|
||||
foreach ($flag->rules as $rule) {
|
||||
if ($this->matchRule($rule, $context)) {
|
||||
$this->logger->info(sprintf('Flag "%s" enabled via rule (attribute: %s, operator: %s).', $flag->name, $rule->attribute, $rule->operator));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Percentage rollout if available
|
||||
if ($flag->rolloutPercentage !== null) {
|
||||
$key = $this->resolveTargetingKey($context);
|
||||
$key = $this->resolveTargetingKey($context, $flag->targetingKey);
|
||||
if ($key === null) {
|
||||
$this->logger->info(sprintf('Flag "%s" rollout evaluation failed: no targeting key found. Using default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE'));
|
||||
return $default;
|
||||
}
|
||||
$bucket = $this->computeBucket($flag->name, (string)$key);
|
||||
return $bucket < $flag->rolloutPercentage;
|
||||
$result = $bucket < $flag->rolloutPercentage;
|
||||
$this->logger->info(sprintf(
|
||||
'Flag "%s" evaluated to %s via rolloutPercentage (%d%%). Bucket: %d, Key: %s.',
|
||||
$flag->name,
|
||||
$result ? 'TRUE' : 'FALSE',
|
||||
$flag->rolloutPercentage,
|
||||
$bucket,
|
||||
$key
|
||||
));
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 4) Fallback
|
||||
// 5) Fallback
|
||||
$this->logger->info(sprintf('Flag "%s" using fallback default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE'));
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function resolveTargetingKey(Context $context): ?string
|
||||
private function matchRule(Rule $rule, Context $context): bool
|
||||
{
|
||||
$attributeValue = $context->get($rule->attribute);
|
||||
|
||||
return match ($rule->operator) {
|
||||
'eq' => $attributeValue === $rule->value,
|
||||
'neq' => $attributeValue !== $rule->value,
|
||||
'gt' => $attributeValue > $rule->value,
|
||||
'gte' => $attributeValue >= $rule->value,
|
||||
'lt' => $attributeValue < $rule->value,
|
||||
'lte' => $attributeValue <= $rule->value,
|
||||
'in' => is_array($rule->value) && in_array($attributeValue, $rule->value, true),
|
||||
'nin' => is_array($rule->value) && !in_array($attributeValue, $rule->value, true),
|
||||
'contains' => is_string($attributeValue) && is_string($rule->value) && str_contains($attributeValue, $rule->value),
|
||||
default => throw new \LogicException(sprintf('Unsupported operator "%s" encountered during evaluation.', $rule->operator)),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveTargetingKey(Context $context, ?string $preferredKey = null): ?string
|
||||
{
|
||||
if ($preferredKey !== null) {
|
||||
$v = $context->get($preferredKey);
|
||||
if ($v !== null && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = ['key', 'userId', 'id', 'email'];
|
||||
foreach ($candidates as $attr) {
|
||||
$v = $context->get($attr);
|
||||
|
|
@ -51,10 +109,8 @@ final class Evaluator
|
|||
|
||||
private function computeBucket(string $flagName, string $key): int
|
||||
{
|
||||
$hash = crc32($flagName . ':' . $key);
|
||||
// Normalize to unsigned 32-bit to avoid negative values on some platforms
|
||||
$unsigned = (int) sprintf('%u', $hash);
|
||||
// Map to 0..99
|
||||
return (int)($unsigned % 100);
|
||||
$hash = hash('xxh3', $flagName . ':' . $key);
|
||||
|
||||
return (int)(hexdec(substr($hash, 0, 8)) % 100);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@ declare(strict_types=1);
|
|||
namespace FlagPole;
|
||||
|
||||
use FlagPole\Repository\FlagRepositoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class FeatureManager
|
||||
{
|
||||
private Evaluator $evaluator;
|
||||
|
||||
public function __construct(
|
||||
private FlagRepositoryInterface $repository,
|
||||
private Evaluator $evaluator = new Evaluator()
|
||||
?Evaluator $evaluator = null,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
$this->evaluator = $evaluator ?? new Evaluator($logger);
|
||||
}
|
||||
|
||||
public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ final class Flag
|
|||
public readonly ?int $rolloutPercentage = null,
|
||||
/** @var list<string> */
|
||||
public readonly array $allowList = [],
|
||||
/** @var list<Rule> */
|
||||
public readonly array $rules = [],
|
||||
public readonly ?string $targetingKey = null,
|
||||
) {
|
||||
if ($this->rolloutPercentage !== null) {
|
||||
if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) {
|
||||
|
|
|
|||
51
src/FlagHydrator.php
Normal file
51
src/FlagHydrator.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
/**
|
||||
* Hydrates Flag objects from raw configuration arrays.
|
||||
*/
|
||||
final class FlagHydrator
|
||||
{
|
||||
private const VALID_OPERATORS = [
|
||||
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'contains'
|
||||
];
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>, rules?:list<array{attribute:string, operator:string, value:mixed}>, targetingKey?:string|null} $data
|
||||
* @return Flag
|
||||
*/
|
||||
public function hydrate(string $name, array $data): Flag
|
||||
{
|
||||
$rules = [];
|
||||
if (isset($data['rules']) && is_array($data['rules'])) {
|
||||
foreach ($data['rules'] as $ruleDef) {
|
||||
if (!isset($ruleDef['attribute'], $ruleDef['operator'], $ruleDef['value'])) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid rule definition for flag "%s". Missing attribute, operator, or value.', $name));
|
||||
}
|
||||
|
||||
if (!in_array($ruleDef['operator'], self::VALID_OPERATORS, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid operator "%s" for flag "%s".', $ruleDef['operator'], $name));
|
||||
}
|
||||
|
||||
$rules[] = new Rule(
|
||||
attribute: $ruleDef['attribute'],
|
||||
operator: $ruleDef['operator'],
|
||||
value: $ruleDef['value']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Flag(
|
||||
name: $name,
|
||||
enabled: $data['enabled'] ?? null,
|
||||
rolloutPercentage: $data['rolloutPercentage'] ?? null,
|
||||
allowList: $data['allowList'] ?? [],
|
||||
rules: $rules,
|
||||
targetingKey: $data['targetingKey'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
namespace FlagPole\Repository;
|
||||
|
||||
use FlagPole\Flag;
|
||||
use FlagPole\FlagHydrator;
|
||||
use FlagPole\Rule;
|
||||
|
||||
/**
|
||||
* Simple in-memory repository. Useful for bootstrapping or tests.
|
||||
|
|
@ -15,18 +17,14 @@ final class InMemoryFlagRepository implements FlagRepositoryInterface
|
|||
private array $flags = [];
|
||||
|
||||
/**
|
||||
* @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>}> $config
|
||||
* @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>, rules?:list<array{attribute:string, operator:string, value:mixed}>, targetingKey?:string|null}> $config
|
||||
*/
|
||||
public static function fromArray(array $config): self
|
||||
{
|
||||
$hydrator = new FlagHydrator();
|
||||
$items = [];
|
||||
foreach ($config as $name => $def) {
|
||||
$items[$name] = new Flag(
|
||||
name: (string)$name,
|
||||
enabled: $def['enabled'] ?? null,
|
||||
rolloutPercentage: $def['rolloutPercentage'] ?? null,
|
||||
allowList: $def['allowList'] ?? []
|
||||
);
|
||||
$items[$name] = $hydrator->hydrate((string)$name, $def);
|
||||
}
|
||||
return new self($items);
|
||||
}
|
||||
|
|
|
|||
18
src/Rule.php
Normal file
18
src/Rule.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
/**
|
||||
* A rule defines a condition that must be met for a flag to be enabled.
|
||||
*/
|
||||
final class Rule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $attribute,
|
||||
public readonly string $operator,
|
||||
public readonly mixed $value,
|
||||
) {
|
||||
}
|
||||
}
|
||||
107
tests/ComprehensiveTest.php
Normal file
107
tests/ComprehensiveTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Tests;
|
||||
|
||||
use FlagPole\Context;
|
||||
use FlagPole\Evaluator;
|
||||
use FlagPole\Flag;
|
||||
use FlagPole\FlagHydrator;
|
||||
use FlagPole\Rule;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ComprehensiveTest extends TestCase
|
||||
{
|
||||
public function testFlagValidation(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('rolloutPercentage must be between 0 and 100');
|
||||
new Flag('invalid', rolloutPercentage: 101);
|
||||
}
|
||||
|
||||
public function testFlagValidationNegative(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('rolloutPercentage must be between 0 and 100');
|
||||
new Flag('invalid', rolloutPercentage: -1);
|
||||
}
|
||||
|
||||
public function testHydratorMissingFields(): void
|
||||
{
|
||||
$hydrator = new FlagHydrator();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid rule definition for flag "test". Missing attribute, operator, or value.');
|
||||
$hydrator->hydrate('test', [
|
||||
'rules' => [
|
||||
['attribute' => 'plan'] // missing operator and value
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function testHydratorInvalidOperator(): void
|
||||
{
|
||||
$hydrator = new FlagHydrator();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid operator "invalid_op" for flag "test".');
|
||||
$hydrator->hydrate('test', [
|
||||
'rules' => [
|
||||
['attribute' => 'plan', 'operator' => 'invalid_op', 'value' => 'pro']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider operatorProvider
|
||||
*/
|
||||
public function testAllOperators(string $operator, mixed $ruleValue, mixed $contextValue, bool $expected): void
|
||||
{
|
||||
$evaluator = new Evaluator();
|
||||
$flag = new Flag('test', rules: [
|
||||
new Rule('attr', $operator, $ruleValue)
|
||||
]);
|
||||
$context = Context::fromArray(['attr' => $contextValue]);
|
||||
|
||||
$this->assertSame($expected, $evaluator->evaluate($flag, $context));
|
||||
}
|
||||
|
||||
public static function operatorProvider(): array
|
||||
{
|
||||
return [
|
||||
'eq match' => ['eq', 'val', 'val', true],
|
||||
'eq mismatch' => ['eq', 'val', 'other', false],
|
||||
'neq match' => ['neq', 'val', 'other', true],
|
||||
'neq mismatch' => ['neq', 'val', 'val', false],
|
||||
'gt match' => ['gt', 10, 11, true],
|
||||
'gt mismatch' => ['gt', 10, 10, false],
|
||||
'gte match eq' => ['gte', 10, 10, true],
|
||||
'gte match gt' => ['gte', 10, 11, true],
|
||||
'gte mismatch' => ['gte', 10, 9, false],
|
||||
'lt match' => ['lt', 10, 9, true],
|
||||
'lt mismatch' => ['lt', 10, 10, false],
|
||||
'lte match eq' => ['lte', 10, 10, true],
|
||||
'lte match lt' => ['lte', 10, 9, true],
|
||||
'lte mismatch' => ['lte', 10, 11, false],
|
||||
'in match' => ['in', ['a', 'b'], 'a', true],
|
||||
'in mismatch' => ['in', ['a', 'b'], 'c', false],
|
||||
'nin match' => ['nin', ['a', 'b'], 'c', true],
|
||||
'nin mismatch' => ['nin', ['a', 'b'], 'a', false],
|
||||
'contains match' => ['contains', 'foo', 'foobar', true],
|
||||
'contains mismatch' => ['contains', 'baz', 'foobar', false],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRuntimeUnsupportedOperator(): void
|
||||
{
|
||||
$evaluator = new Evaluator();
|
||||
// We bypass the hydrator which has validation to test the evaluator safety
|
||||
$flag = new Flag('test', rules: [
|
||||
new Rule('attr', 'unsupported', 'val')
|
||||
]);
|
||||
$context = Context::fromArray(['attr' => 'val']);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('Unsupported operator "unsupported" encountered during evaluation.');
|
||||
$evaluator->evaluate($flag, $context);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use FlagPole\Context;
|
|||
use FlagPole\FeatureManager;
|
||||
use FlagPole\Repository\InMemoryFlagRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\AbstractLogger;
|
||||
|
||||
final class FeatureManagerTest extends TestCase
|
||||
{
|
||||
|
|
@ -23,6 +24,27 @@ final class FeatureManagerTest extends TestCase
|
|||
$this->assertFalse($fm->isEnabled('hard-off', null, true));
|
||||
}
|
||||
|
||||
public function testLogging(): void
|
||||
{
|
||||
$logger = new class extends AbstractLogger {
|
||||
public array $logs = [];
|
||||
public function log($level, string|\Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->logs[] = (string)$message;
|
||||
}
|
||||
};
|
||||
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'test-flag' => ['enabled' => true],
|
||||
]);
|
||||
$fm = new FeatureManager($repo, null, $logger);
|
||||
|
||||
$fm->isEnabled('test-flag');
|
||||
|
||||
$this->assertCount(1, $logger->logs);
|
||||
$this->assertStringContainsString('Flag "test-flag" evaluated to TRUE via explicit "enabled" override', $logger->logs[0]);
|
||||
}
|
||||
|
||||
public function testAllowListWins(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
|
|
@ -116,4 +138,70 @@ final class FeatureManagerTest extends TestCase
|
|||
$this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false));
|
||||
$this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true));
|
||||
}
|
||||
|
||||
public function testRulesMatching(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'beta-users' => [
|
||||
'rules' => [
|
||||
['attribute' => 'group', 'operator' => 'eq', 'value' => 'beta'],
|
||||
],
|
||||
],
|
||||
'version-check' => [
|
||||
'rules' => [
|
||||
['attribute' => 'version', 'operator' => 'gte', 'value' => '2.0'],
|
||||
],
|
||||
],
|
||||
'multi-rules' => [
|
||||
'rules' => [
|
||||
['attribute' => 'plan', 'operator' => 'in', 'value' => ['pro', 'enterprise']],
|
||||
['attribute' => 'region', 'operator' => 'eq', 'value' => 'us-east'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
|
||||
// Single rule 'eq'
|
||||
$this->assertTrue($fm->isEnabled('beta-users', Context::fromArray(['group' => 'beta'])));
|
||||
$this->assertFalse($fm->isEnabled('beta-users', Context::fromArray(['group' => 'standard'])));
|
||||
|
||||
// Single rule 'gte'
|
||||
$this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.0'])));
|
||||
$this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.1'])));
|
||||
$this->assertFalse($fm->isEnabled('version-check', Context::fromArray(['version' => '1.9'])));
|
||||
|
||||
// Multi rules (OR behavior because it matches ANY rule in the list as implemented currently)
|
||||
// Wait, the plan said: "Precedence: allowList > enabled > rules > rolloutPercentage"
|
||||
// And the implementation I did:
|
||||
/*
|
||||
if (!empty($flag->rules)) {
|
||||
foreach ($flag->rules as $rule) {
|
||||
if ($this->matchRule($rule, $context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
// This is indeed OR behavior. If ANY rule matches, it returns true.
|
||||
$this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'pro'])));
|
||||
$this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['region' => 'us-east'])));
|
||||
$this->assertFalse($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'free', 'region' => 'eu-west'])));
|
||||
}
|
||||
|
||||
public function testExplicitTargetingKey(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'custom-key' => [
|
||||
'targetingKey' => 'orgId',
|
||||
'allowList' => ['org_123'],
|
||||
],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
|
||||
$ctxMatched = Context::fromArray(['orgId' => 'org_123']);
|
||||
$ctxUnmatched = Context::fromArray(['orgId' => 'org_456', 'userId' => 'org_123']); // userId matches but orgId doesn't
|
||||
|
||||
$this->assertTrue($fm->isEnabled('custom-key', $ctxMatched));
|
||||
$this->assertFalse($fm->isEnabled('custom-key', $ctxUnmatched));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue