Compare commits
4 commits
141554a854
...
b7b534fa5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b534fa5a | ||
|
|
a326084346 | ||
|
|
1951ce9c7d | ||
|
|
5043031a33 |
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,4 +6,4 @@
|
|||
composer.lock
|
||||
/.php-cs-fixer.cache
|
||||
/.phpstan/
|
||||
.junie.json
|
||||
.junie
|
||||
59
MILESTONES.md
Normal file
59
MILESTONES.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# FlagPole Milestones
|
||||
|
||||
This document outlines the roadmap for enhancing FlagPole with advanced targeting, persistence, and observability features.
|
||||
|
||||
## Table of Contents
|
||||
- [Milestone 1: Advanced Rule Engine](#milestone-1-advanced-rule-engine)
|
||||
- [Milestone 2: Pluggable Targeting Keys](#milestone-2-pluggable-targeting-keys)
|
||||
- [Milestone 3: Persistent Repositories](#milestone-3-persistent-repositories)
|
||||
- [Milestone 4: Observability & Logging](#milestone-4-observability--logging)
|
||||
- [Milestone 5: Documentation & Polish](#milestone-5-documentation--polish)
|
||||
- [Milestone 6: Refactoring & DRY](#milestone-6-refactoring--dry)
|
||||
- [Milestone 7: Validation & Safety](#milestone-7-validation--safety)
|
||||
|
||||
## Milestone 1: Advanced Rule Engine
|
||||
Move beyond simple allow-lists to support complex attribute-based targeting.
|
||||
|
||||
- [x] Define `Rule` DTO/Interface (`src/Rule.php`).
|
||||
- [x] Add `rules` collection to `Flag` DTO.
|
||||
- [x] Implement rule evaluation logic in `Evaluator` (supporting operators like `eq`, `gt`, `lt`, `in`, `contains`).
|
||||
- [x] Update `Evaluator::evaluate` precedence: `allowList` > `enabled` > `rules` > `rolloutPercentage`.
|
||||
|
||||
## Milestone 2: Pluggable Targeting Keys
|
||||
Allow explicit control over which context attribute is used for rollout hashing.
|
||||
|
||||
- [x] Add optional `targetingKey` property to `Flag`.
|
||||
- [x] Refactor `Evaluator::resolveTargetingKey` to honor `Flag->targetingKey` if present.
|
||||
- [x] Modernize hashing implementation in `Evaluator::computeBucket` for PHP 8.2+.
|
||||
|
||||
## Milestone 3: Persistent Repositories
|
||||
Provide out-of-the-box support for non-volatile flag storage.
|
||||
|
||||
- [x] Implement `JsonFileRepository` in `src/Repository/JsonFileRepository.php`.
|
||||
- [x] Ensure robust JSON parsing and mapping to `Flag` objects.
|
||||
|
||||
## Milestone 4: Observability & Logging
|
||||
Provide insights into why flags are being enabled or disabled.
|
||||
|
||||
- [x] Integrate PSR-3 `LoggerInterface` into `FeatureManager` and `Evaluator`.
|
||||
- [x] Implement detailed logging for evaluation outcomes (e.g., which rule or strategy matched).
|
||||
- [x] (Optional) Create `EvaluationResult` DTO for programmatic access to evaluation reasons.
|
||||
|
||||
## Milestone 5: Documentation & Polish
|
||||
- [x] Update `README.md` with examples for new features.
|
||||
- [x] Add comprehensive tests for Rule Engine and JSON Repository.
|
||||
- [x] Verify zero regression for existing simple flag usage.
|
||||
|
||||
## Milestone 6: Refactoring & DRY
|
||||
Centralize logic and remove duplication to improve maintainability.
|
||||
|
||||
- [x] Extract flag hydration logic into a dedicated `FlagHydrator` class to be reused across repositories.
|
||||
- [x] Refactor `InMemoryFlagRepository` to use the new hydration logic.
|
||||
- [x] Refactor `JsonFileRepository` to use the new hydration logic.
|
||||
|
||||
## Milestone 7: Validation & Safety
|
||||
Enhance the engine to be more robust and developer-friendly.
|
||||
|
||||
- [x] Add validation to the rule engine to handle or log unknown operators instead of failing silently.
|
||||
- [x] Implement configuration validation to ensure `Flag` and `Rule` objects are correctly formed before evaluation.
|
||||
- [x] Optimize `JsonFileRepository` to avoid unnecessary parsing or consider lazy-loading if the config grows large.
|
||||
31
README.md
31
README.md
|
|
@ -4,6 +4,8 @@ Feature flag handling for PHP. Simple, framework-agnostic, and lightweight.
|
|||
|
||||

|
||||

|
||||
[](https://packagist.org/packages/getphred/pairity)
|
||||
[](LICENSE.md)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -27,6 +29,9 @@ $repo = InMemoryFlagRepository::fromArray([
|
|||
'enabled' => null, // not a hard on/off
|
||||
'rolloutPercentage' => 25, // 25% gradual rollout
|
||||
'allowList' => ['user_1'], // always on for specific users
|
||||
'rules' => [ // attribute-based rules
|
||||
['attribute' => 'plan', 'operator' => 'eq', 'value' => 'pro'],
|
||||
],
|
||||
],
|
||||
'hard-off' => [ 'enabled' => false ],
|
||||
'hard-on' => [ 'enabled' => true ],
|
||||
|
|
@ -34,7 +39,7 @@ $repo = InMemoryFlagRepository::fromArray([
|
|||
|
||||
$flags = new FeatureManager($repo);
|
||||
|
||||
$context = Context::fromArray(['userId' => 'user_42']);
|
||||
$context = Context::fromArray(['userId' => 'user_42', 'plan' => 'pro']);
|
||||
|
||||
if ($flags->isEnabled('new-dashboard', $context, false)) {
|
||||
// show the new dashboard
|
||||
|
|
@ -47,31 +52,27 @@ if ($flags->isEnabled('new-dashboard', $context, false)) {
|
|||
|
||||
- Flag: has a `name` and optional strategies:
|
||||
- `enabled`: explicit boolean on/off overrides everything.
|
||||
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash of the flag name + user key.
|
||||
- `allowList`: list of user keys that always get the flag enabled.
|
||||
- `rules`: complex attribute targeting (e.g. `version > 2.0`, `plan == 'pro'`).
|
||||
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash.
|
||||
- Context: attributes about the subject (e.g. `userId`, `email`) used for evaluation.
|
||||
- Repository: source of truth for flags. Provided: `InMemoryFlagRepository`. You can implement your own.
|
||||
- Repository: source of truth for flags. Provided: `InMemoryFlagRepository`, `JsonFileRepository`.
|
||||
- Hydration: `FlagHydrator` centralizes flag creation and provides validation for targeting rules.
|
||||
- Observability: Optional PSR-3 logging of evaluation results and reasons.
|
||||
|
||||
## Targeting key
|
||||
|
||||
Evaluator looks for a stable key in the context in this order: `key`, `userId`, `id`, `email`.
|
||||
|
||||
## Rollout hashing and boundary behavior
|
||||
|
||||
- Stable bucketing uses `crc32(flagName:key)` normalized to an unsigned 32-bit integer, then mapped to buckets 0..99.
|
||||
- This guarantees consistent behavior across 32-bit and 64-bit platforms.
|
||||
- Boundary rules:
|
||||
- 0% rollout always evaluates to `false` when a targeting key is present.
|
||||
- 100% rollout always evaluates to `true` when a targeting key is present.
|
||||
- If no targeting key is present in the `Context`, percentage rollout falls back to the `default` you pass to `isEnabled()`.
|
||||
You can also specify an explicit `targetingKey` per flag to use a specific attribute (e.g. `orgId`).
|
||||
|
||||
## Precedence semantics
|
||||
|
||||
When evaluating a flag, the following precedence applies:
|
||||
1. `allowList` — if the targeting key is in the allow-list, the flag is enabled.
|
||||
2. `enabled` — explicit on/off overrides percentage rollout and defaults.
|
||||
3. `rolloutPercentage` — uses stable bucketing over the targeting key.
|
||||
4. Fallback — returns the provided default when none of the above apply.
|
||||
2. `enabled` — explicit on/off overrides everything below.
|
||||
3. `rules` — attribute-based targeting rules.
|
||||
4. `rolloutPercentage` — uses stable bucketing over the targeting key.
|
||||
5. Fallback — returns the provided default when none of the above apply.
|
||||
|
||||
## Framework integration
|
||||
|
||||
|
|
|
|||
54
SPECS.md
Normal file
54
SPECS.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# FlagPole Specifications
|
||||
|
||||
This document describes the current technical specifications and architecture of the FlagPole feature flagging library.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Flag
|
||||
An immutable data object representing a feature flag and its evaluation strategies.
|
||||
- **Properties**:
|
||||
- `name` (string): Unique identifier.
|
||||
- `enabled` (bool|null): Explicit override. If `true`/`false`, evaluation stops here.
|
||||
- `rolloutPercentage` (int|null): 0-100 value for gradual rollout.
|
||||
- `allowList` (list<string>): Keys that always receive `true`.
|
||||
- `rules` (list<Rule>): Complex attribute-based targeting rules.
|
||||
- `targetingKey` (string|null): Optional override for the attribute used in rollout/allowList.
|
||||
|
||||
### Context
|
||||
A container for attributes (e.g., `userId`, `email`, `userGroup`) used during evaluation.
|
||||
- **Targeting Keys**: By default, the evaluator looks for `key`, `userId`, `id`, or `email` (in that order), unless overridden by `Flag->targetingKey`.
|
||||
|
||||
### Evaluator
|
||||
The engine that applies flag strategies against a context.
|
||||
- **Precedence**:
|
||||
1. `allowList`: If the targeting key is in the list, return `true`.
|
||||
2. `enabled`: If non-null, return its boolean value.
|
||||
3. `rules`: If any rule matches the context attributes, return `true`.
|
||||
4. `rolloutPercentage`: Hash-based stable bucketing (0-99).
|
||||
5. `fallback`: Returns the user-provided default.
|
||||
|
||||
### FeatureManager
|
||||
The main entry point for the consumer.
|
||||
- **Method**: `isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool`
|
||||
- **Logging**: Supports PSR-3 logging of evaluation reasons.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Rollout Hashing
|
||||
- Algorithm: `xxh3(flagName + ":" + targetingKey)`.
|
||||
- Normalization: `hexdec(substr(hash, 0, 8)) % 100`.
|
||||
- Bucketing: 0-99.
|
||||
|
||||
### Repositories
|
||||
- `FlagRepositoryInterface`: Defines `get(string $name)` and `all()`.
|
||||
- `InMemoryFlagRepository`: Provided for testing and simple setups.
|
||||
- `JsonFileRepository`: Loads flags from a JSON file. Now supports lazy-loading hydration for better performance.
|
||||
|
||||
### FlagHydrator
|
||||
A dedicated component responsible for transforming raw configuration arrays into validated `Flag` and `Rule` objects.
|
||||
- **Validation**: Ensures all rules have required fields and valid operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`).
|
||||
- **Reuse**: Used by both `InMemoryFlagRepository` and `JsonFileRepository` to ensure consistent flag creation.
|
||||
|
||||
## Constraints
|
||||
- PHP 8.2+ required.
|
||||
- No external dependencies (PSR interfaces excepted in future).
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
93
src/Repository/JsonFileRepository.php
Normal file
93
src/Repository/JsonFileRepository.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Repository;
|
||||
|
||||
use FlagPole\Flag;
|
||||
use FlagPole\FlagHydrator;
|
||||
use FlagPole\Rule;
|
||||
|
||||
/**
|
||||
* Loads flags from a JSON file.
|
||||
*/
|
||||
final class JsonFileRepository implements FlagRepositoryInterface
|
||||
{
|
||||
/** @var array<string, array<string, mixed>>|null */
|
||||
private ?array $rawData = null;
|
||||
|
||||
/** @var array<string, Flag> */
|
||||
private array $flags = [];
|
||||
|
||||
private ?FlagHydrator $hydrator = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $filePath
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $name): ?Flag
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
if (isset($this->flags[$name])) {
|
||||
return $this->flags[$name];
|
||||
}
|
||||
|
||||
if (isset($this->rawData[$name])) {
|
||||
$this->hydrator ??= new FlagHydrator();
|
||||
$this->flags[$name] = $this->hydrator->hydrate($name, $this->rawData[$name]);
|
||||
return $this->flags[$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, Flag>
|
||||
*/
|
||||
public function all(): iterable
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
if ($this->rawData !== null) {
|
||||
foreach (array_keys($this->rawData) as $name) {
|
||||
$this->get((string)$name);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->flags;
|
||||
}
|
||||
|
||||
private function ensureLoaded(): void
|
||||
{
|
||||
if ($this->rawData !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file_exists($this->filePath)) {
|
||||
$this->rawData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($this->filePath);
|
||||
if ($content === false) {
|
||||
$this->rawData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$this->rawData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$this->rawData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rawData = $data;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
tests/JsonFileRepositoryTest.php
Normal file
96
tests/JsonFileRepositoryTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Tests;
|
||||
|
||||
use FlagPole\Repository\JsonFileRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class JsonFileRepositoryTest extends TestCase
|
||||
{
|
||||
private string $tempFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tempFile = tempnam(sys_get_temp_dir(), 'flagpole_test_');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (file_exists($this->tempFile)) {
|
||||
unlink($this->tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testLoadsFlagsFromJson(): void
|
||||
{
|
||||
$json = json_encode([
|
||||
'test-flag' => [
|
||||
'enabled' => true,
|
||||
'rules' => [
|
||||
['attribute' => 'plan', 'operator' => 'eq', 'value' => 'pro']
|
||||
]
|
||||
]
|
||||
]);
|
||||
file_put_contents($this->tempFile, $json);
|
||||
|
||||
$repo = new JsonFileRepository($this->tempFile);
|
||||
$flag = $repo->get('test-flag');
|
||||
|
||||
$this->assertNotNull($flag);
|
||||
$this->assertEquals('test-flag', $flag->name);
|
||||
$this->assertTrue($flag->enabled);
|
||||
$this->assertCount(1, $flag->rules);
|
||||
$this->assertEquals('plan', $flag->rules[0]->attribute);
|
||||
}
|
||||
|
||||
public function testHandlesMissingFile(): void
|
||||
{
|
||||
$repo = new JsonFileRepository('/non/existent/file.json');
|
||||
$this->assertNull($repo->get('any'));
|
||||
$this->assertEmpty($repo->all());
|
||||
}
|
||||
|
||||
public function testHandlesInvalidJson(): void
|
||||
{
|
||||
file_put_contents($this->tempFile, 'invalid json');
|
||||
$repo = new JsonFileRepository($this->tempFile);
|
||||
$this->assertNull($repo->get('any'));
|
||||
}
|
||||
|
||||
public function testLazyHydration(): void
|
||||
{
|
||||
$json = json_encode([
|
||||
'flag-1' => ['enabled' => true],
|
||||
'flag-2' => ['enabled' => false],
|
||||
]);
|
||||
file_put_contents($this->tempFile, $json);
|
||||
|
||||
$repo = new JsonFileRepository($this->tempFile);
|
||||
|
||||
// Access internal property via reflection to verify it's NOT hydrated yet
|
||||
$reflection = new \ReflectionClass($repo);
|
||||
$flagsProp = $reflection->getProperty('flags');
|
||||
$flagsProp->setAccessible(true);
|
||||
|
||||
$this->assertEmpty($flagsProp->getValue($repo));
|
||||
|
||||
// Hydrate flag-1
|
||||
$flag1 = $repo->get('flag-1');
|
||||
$this->assertNotNull($flag1);
|
||||
$this->assertTrue($flag1->enabled);
|
||||
|
||||
$hydratedFlags = $flagsProp->getValue($repo);
|
||||
$this->assertArrayHasKey('flag-1', $hydratedFlags);
|
||||
$this->assertArrayNotHasKey('flag-2', $hydratedFlags);
|
||||
|
||||
// Hydrate all
|
||||
$all = $repo->all();
|
||||
$this->assertCount(2, $all);
|
||||
|
||||
$hydratedFlagsAfterAll = $flagsProp->getValue($repo);
|
||||
$this->assertArrayHasKey('flag-1', $hydratedFlagsAfterAll);
|
||||
$this->assertArrayHasKey('flag-2', $hydratedFlagsAfterAll);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue