feat: implement advanced rule engine and targeting

This commit is contained in:
Funky Waddle 2026-02-12 00:54:30 -06:00
parent 5043031a33
commit 1951ce9c7d
8 changed files with 345 additions and 19 deletions

View file

@ -4,41 +4,99 @@ declare(strict_types=1);
namespace FlagPole; namespace FlagPole;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
final class Evaluator 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 public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool
{ {
$context ??= new Context(); $context ??= new Context();
// 1) Allow list wins if present // 1) Allow list wins if present
if (!empty($flag->allowList)) { if (!empty($flag->allowList)) {
$key = $this->resolveTargetingKey($context); $key = $this->resolveTargetingKey($context, $flag->targetingKey);
if ($key !== null && in_array((string)$key, $flag->allowList, true)) { 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; return true;
} }
} }
// 2) Explicit on/off // 2) Explicit on/off
if ($flag->enabled !== null) { 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; 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) { if ($flag->rolloutPercentage !== null) {
$key = $this->resolveTargetingKey($context); $key = $this->resolveTargetingKey($context, $flag->targetingKey);
if ($key === null) { 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; return $default;
} }
$bucket = $this->computeBucket($flag->name, (string)$key); $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; 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']; $candidates = ['key', 'userId', 'id', 'email'];
foreach ($candidates as $attr) { foreach ($candidates as $attr) {
$v = $context->get($attr); $v = $context->get($attr);
@ -51,10 +109,8 @@ final class Evaluator
private function computeBucket(string $flagName, string $key): int private function computeBucket(string $flagName, string $key): int
{ {
$hash = crc32($flagName . ':' . $key); $hash = hash('xxh3', $flagName . ':' . $key);
// Normalize to unsigned 32-bit to avoid negative values on some platforms
$unsigned = (int) sprintf('%u', $hash); return (int)(hexdec(substr($hash, 0, 8)) % 100);
// Map to 0..99
return (int)($unsigned % 100);
} }
} }

View file

@ -5,13 +5,18 @@ declare(strict_types=1);
namespace FlagPole; namespace FlagPole;
use FlagPole\Repository\FlagRepositoryInterface; use FlagPole\Repository\FlagRepositoryInterface;
use Psr\Log\LoggerInterface;
final class FeatureManager final class FeatureManager
{ {
private Evaluator $evaluator;
public function __construct( public function __construct(
private FlagRepositoryInterface $repository, 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 public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool

View file

@ -15,6 +15,9 @@ final class Flag
public readonly ?int $rolloutPercentage = null, public readonly ?int $rolloutPercentage = null,
/** @var list<string> */ /** @var list<string> */
public readonly array $allowList = [], public readonly array $allowList = [],
/** @var list<Rule> */
public readonly array $rules = [],
public readonly ?string $targetingKey = null,
) { ) {
if ($this->rolloutPercentage !== null) { if ($this->rolloutPercentage !== null) {
if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) { if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) {

51
src/FlagHydrator.php Normal file
View 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
);
}
}

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace FlagPole\Repository; namespace FlagPole\Repository;
use FlagPole\Flag; use FlagPole\Flag;
use FlagPole\FlagHydrator;
use FlagPole\Rule;
/** /**
* Simple in-memory repository. Useful for bootstrapping or tests. * Simple in-memory repository. Useful for bootstrapping or tests.
@ -15,18 +17,14 @@ final class InMemoryFlagRepository implements FlagRepositoryInterface
private array $flags = []; 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 public static function fromArray(array $config): self
{ {
$hydrator = new FlagHydrator();
$items = []; $items = [];
foreach ($config as $name => $def) { foreach ($config as $name => $def) {
$items[$name] = new Flag( $items[$name] = $hydrator->hydrate((string)$name, $def);
name: (string)$name,
enabled: $def['enabled'] ?? null,
rolloutPercentage: $def['rolloutPercentage'] ?? null,
allowList: $def['allowList'] ?? []
);
} }
return new self($items); return new self($items);
} }

18
src/Rule.php Normal file
View 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
View 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);
}
}

View file

@ -8,6 +8,7 @@ use FlagPole\Context;
use FlagPole\FeatureManager; use FlagPole\FeatureManager;
use FlagPole\Repository\InMemoryFlagRepository; use FlagPole\Repository\InMemoryFlagRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
final class FeatureManagerTest extends TestCase final class FeatureManagerTest extends TestCase
{ {
@ -23,6 +24,27 @@ final class FeatureManagerTest extends TestCase
$this->assertFalse($fm->isEnabled('hard-off', null, true)); $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 public function testAllowListWins(): void
{ {
$repo = InMemoryFlagRepository::fromArray([ $repo = InMemoryFlagRepository::fromArray([
@ -116,4 +138,70 @@ final class FeatureManagerTest extends TestCase
$this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false)); $this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false));
$this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true)); $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));
}
} }