diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cd4bd7..aed4718 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: run: composer validate --strict - name: Cache Composer packages - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: vendor key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} diff --git a/src/EvaluationCore/EvaluationEngine.php b/src/EvaluationCore/EvaluationEngine.php index 8590b8a..140b0eb 100644 --- a/src/EvaluationCore/EvaluationEngine.php +++ b/src/EvaluationCore/EvaluationEngine.php @@ -1,11 +1,22 @@ <?php namespace AmplitudeExperiment\EvaluationCore; + +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationVariant; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationSegment; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationCondition; use Exception; require_once __DIR__ . '/Util.php'; + class EvaluationEngine { + /** + * @param array<string, mixed> $context + * @param EvaluationFlag[] $flags + * @return array<string, EvaluationVariant> + */ public function evaluate(array $context, array $flags): array { $results = []; @@ -17,46 +28,62 @@ public function evaluate(array $context, array $flags): array foreach ($flags as $flag) { $variant = $this->evaluateFlag($target, $flag); if ($variant !== null) { - $results[$flag['key']] = $variant; + $results[$flag->key] = $variant; } } return $results; } - private function evaluateFlag(array $target, array $flag): ?array + /** + * @param array<string, mixed> $target + * @param EvaluationFlag $flag + * @return EvaluationVariant|null + */ + private function evaluateFlag(array $target, EvaluationFlag $flag): ?EvaluationVariant { $result = null; - foreach ($flag['segments'] as $segment) { + foreach ($flag->segments as $segment) { $result = $this->evaluateSegment($target, $flag, $segment); if ($result !== null) { $metadata = array_merge( - $flag['metadata'] ?? [], - $segment['metadata'] ?? [], - $result['metadata'] ?? [] + $flag->metadata ?? [], + $segment->metadata ?? [], + $result->metadata ?? [] + ); + + return new EvaluationVariant( + $result->key, + $result->value, + $result->payload, + $metadata ); - $result = array_merge($result, ['metadata' => $metadata]); - break; } } return $result; } - private function evaluateSegment(array $target, array $flag, array $segment): ?array + /** + * @param array<string, mixed> $target + * @param EvaluationFlag $flag + * @param EvaluationSegment $segment + * @return EvaluationVariant|null + */ + private function evaluateSegment(array $target, EvaluationFlag $flag, EvaluationSegment $segment): ?EvaluationVariant { - if (!isset($segment['conditions'])) { + if ($segment->conditions === null) { $variantKey = $this->bucket($target, $segment); - if ($variantKey !== null) { - return $flag['variants'][$variantKey]; + if ($variantKey !== null && isset($flag->variants[$variantKey])) { + return $flag->variants[$variantKey]; } else { return null; } } - foreach ($segment['conditions'] as $conditions) { + foreach ($segment->conditions as $conditions) { $match = true; foreach ($conditions as $condition) { @@ -70,8 +97,8 @@ private function evaluateSegment(array $target, array $flag, array $segment): ?a if ($match) { $variantKey = $this->bucket($target, $segment); - if ($variantKey !== null) { - return $flag['variants'][$variantKey]; + if ($variantKey !== null && isset($flag->variants[$variantKey])) { + return $flag->variants[$variantKey]; } else { return null; } @@ -81,28 +108,35 @@ private function evaluateSegment(array $target, array $flag, array $segment): ?a return null; } - private function matchCondition(array $target, array $condition): bool + /** + * @param array<string, mixed> $target + * @param EvaluationCondition $condition + * @return bool + */ + private function matchCondition(array $target, EvaluationCondition $condition): bool { - $propValue = select($target, $condition['selector']); + $propValue = select($target, $condition->selector); - if (!$propValue && $propValue !== '0') { - return $this->matchNull($condition['op'], $condition['values']); - } elseif ($this->isSetOperator($condition['op'])) { + if ($propValue === null) { + return $this->matchNull($condition->op, $condition->values); + } elseif (is_bool($propValue)) { + return $this->matchBoolean($propValue, $condition->op, $condition->values); + } elseif ($this->isSetOperator($condition->op)) { $propValueStringList = $this->coerceStringArray($propValue); if ($propValueStringList === null) { return false; } - return $this->matchSet($propValueStringList, $condition['op'], $condition['values']); + return $this->matchSet($propValueStringList, $condition->op, $condition->values); } else { $propValueString = $this->coerceString($propValue); if ($propValueString !== null) { return $this->matchString( $propValueString, - $condition['op'], - $condition['values'] + $condition->op, + $condition->values ); } else { return false; @@ -110,47 +144,61 @@ private function matchCondition(array $target, array $condition): bool } } + /** + * @param string $key + * @return int + */ private function getHash(string $key): int { return Murmur3::hash3_int($key); } - private function bucket(array $target, array $segment): ?string + /** + * @param array<string, mixed> $target + * @param EvaluationSegment $segment + * @return string|null + */ + private function bucket(array $target, EvaluationSegment $segment): ?string { - if (!isset($segment['bucket'])) { - return $segment['variant'] ?? null; + if ($segment->bucket === null) { + return $segment->variant ?? null; } - $bucketingValue = $this->coerceString(select($target, $segment['bucket']['selector'])); + $bucketingValue = $this->coerceString(select($target, $segment->bucket->selector)); if ($bucketingValue === null || strlen($bucketingValue) === 0) { - return $segment['variant'] ?? null; + return $segment->variant ?? null; } - $keyToHash = "{$segment['bucket']['salt']}/$bucketingValue"; + $keyToHash = "{$segment->bucket->salt}/$bucketingValue"; $hash = $this->getHash($keyToHash); $allocationValue = $hash % 100; $distributionValue = floor($hash / 100); - foreach ($segment['bucket']['allocations'] as $allocation) { - $allocationStart = $allocation['range'][0]; - $allocationEnd = $allocation['range'][1]; + foreach ($segment->bucket->allocations as $allocation) { + $allocationStart = $allocation->range[0]; + $allocationEnd = $allocation->range[1]; if ($allocationValue >= $allocationStart && $allocationValue < $allocationEnd) { - foreach ($allocation['distributions'] as $distribution) { - $distributionStart = $distribution['range'][0]; - $distributionEnd = $distribution['range'][1]; + foreach ($allocation->distributions as $distribution) { + $distributionStart = $distribution->range[0]; + $distributionEnd = $distribution->range[1]; if ($distributionValue >= $distributionStart && $distributionValue < $distributionEnd) { - return $distribution['variant']; + return $distribution->variant; } } } } - return $segment['variant'] ?? null; + return $segment->variant ?? null; } + /** + * @param string $op + * @param array<string> $filterValues + * @return bool + */ private function matchNull(string $op, array $filterValues): bool { $containsNone = $this->containsNone($filterValues); @@ -180,6 +228,12 @@ private function matchNull(string $op, array $filterValues): bool } } + /** + * @param array<string> $propValues + * @param string $op + * @param array<string> $filterValues + * @return bool + */ private function matchSet(array $propValues, string $op, array $filterValues): bool { switch ($op) { @@ -200,6 +254,12 @@ private function matchSet(array $propValues, string $op, array $filterValues): b } } + /** + * @param string $propValue + * @param string $op + * @param array<string> $filterValues + * @return bool + */ private function matchString(string $propValue, string $op, array $filterValues): bool { switch ($op) { @@ -219,10 +279,8 @@ private function matchString(string $propValue, string $op, array $filterValues) $propValue, $op, $filterValues, - function ($value) { - return $this->parseNumber($value); - }, - array($this, 'comparator') + fn(string $value): ?int => $this->parseNumber($value), + fn($a, string $op, $b): bool => $this->comparator($a, $op, $b) ); case EvaluationOperator::VERSION_LESS_THAN: case EvaluationOperator::VERSION_LESS_THAN_EQUALS: @@ -232,10 +290,8 @@ function ($value) { $propValue, $op, $filterValues, - function ($value) { - return SemanticVersion::parse($value); - }, - array($this, 'versionComparator') + fn(string $value): ?SemanticVersion => SemanticVersion::parse($value), + fn(SemanticVersion $a, string $op, SemanticVersion $b): bool => $this->versionComparator($a, $op, $b) ); case EvaluationOperator::REGEX_MATCH: return $this->matchesRegex($propValue, $filterValues); @@ -246,21 +302,30 @@ function ($value) { } } + /** + * @param string $propValue + * @param array<string> $filterValues + * @return bool + */ private function matchesIs(string $propValue, array $filterValues): bool { - if ($this->containsBooleans($filterValues)) { - $lower = strtolower($propValue); - if ($lower === 'true' || $lower === 'false') { - foreach ($filterValues as $value) { - if (strtolower($value) === $lower) { - return true; - } - } - } + $lowerFilterValues = array_map('strtolower', $filterValues); + $lowerPropValue = strtolower($propValue); + if (in_array('true', $lowerFilterValues) && in_array($lowerPropValue, ['true', '1'])) { + return true; + } + + if (in_array('false', $lowerFilterValues) && in_array($lowerPropValue, ['false', '0'])) { + return true; } return in_array($propValue, $filterValues); } + /** + * @param string $propValue + * @param array<string> $filterValues + * @return bool + */ private function matchesContains(string $propValue, array $filterValues): bool { foreach ($filterValues as $filterValue) { @@ -350,17 +415,6 @@ private function containsNone(array $filterValues): bool return in_array('(none)', $filterValues); } - private function containsBooleans(array $filterValues): bool - { - foreach ($filterValues as $filterValue) { - $lowercaseFilterValue = strtolower($filterValue); - if ($lowercaseFilterValue === 'true' || $lowercaseFilterValue === 'false') { - return true; - } - } - return false; - } - private function parseNumber(string $value): ?int { $parsedValue = filter_var($value, FILTER_VALIDATE_INT); @@ -387,7 +441,7 @@ private function coerceStringArray($value): ?array try { $parsedValue = json_decode($stringValue, true); if (is_array($parsedValue)) { - return array_filter(array_map([$this, 'coerceString'], $value)); + return array_filter(array_map([$this, 'coerceString'], $parsedValue)); } else { return null; } @@ -442,5 +496,38 @@ private function matchesSetContainsAny(array $propValues, array $filterValues): } return false; } -} + /** + * @param bool $propValue + * @param string $op + * @param array<string> $filterValues + * @return bool + */ + private function matchBoolean(bool $propValue, string $op, array $filterValues): bool + { + $propValueString = $propValue ? 'true' : 'false'; + + switch ($op) { + case EvaluationOperator::IS: + foreach ($filterValues as $value) { + $lowercaseValue = strtolower($value); + if (($propValue && $lowercaseValue === 'true') || + (!$propValue && $lowercaseValue === 'false')) { + return true; + } + } + return false; + case EvaluationOperator::IS_NOT: + foreach ($filterValues as $value) { + $lowercaseValue = strtolower($value); + if (($propValue && $lowercaseValue === 'true') || + (!$propValue && $lowercaseValue === 'false')) { + return false; + } + } + return true; + default: + return $this->matchString($propValueString, $op, $filterValues); + } + } +} diff --git a/src/EvaluationCore/EvaluationOperator.php b/src/EvaluationCore/EvaluationOperator.php index 7f8b9dd..94796a6 100644 --- a/src/EvaluationCore/EvaluationOperator.php +++ b/src/EvaluationCore/EvaluationOperator.php @@ -1,27 +1,28 @@ <?php +declare(strict_types=1); namespace AmplitudeExperiment\EvaluationCore; class EvaluationOperator { - const IS = 'is'; - const IS_NOT = 'is not'; - const CONTAINS = 'contains'; - const DOES_NOT_CONTAIN = 'does not contain'; - const LESS_THAN = 'less'; - const LESS_THAN_EQUALS = 'less or equal'; - const GREATER_THAN = 'greater'; - const GREATER_THAN_EQUALS = 'greater or equal'; - const VERSION_LESS_THAN = 'version less'; - const VERSION_LESS_THAN_EQUALS = 'version less or equal'; - const VERSION_GREATER_THAN = 'version greater'; - const VERSION_GREATER_THAN_EQUALS = 'version greater or equal'; - const SET_IS = 'set is'; - const SET_IS_NOT = 'set is not'; - const SET_CONTAINS = 'set contains'; - const SET_DOES_NOT_CONTAIN = 'set does not contain'; - const SET_CONTAINS_ANY = 'set contains any'; - const SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any'; - const REGEX_MATCH = 'regex match'; - const REGEX_DOES_NOT_MATCH = 'regex does not match'; + public const IS = 'is'; + public const IS_NOT = 'is not'; + public const CONTAINS = 'contains'; + public const DOES_NOT_CONTAIN = 'does not contain'; + public const LESS_THAN = 'less'; + public const LESS_THAN_EQUALS = 'less or equal'; + public const GREATER_THAN = 'greater'; + public const GREATER_THAN_EQUALS = 'greater or equal'; + public const VERSION_LESS_THAN = 'version less'; + public const VERSION_LESS_THAN_EQUALS = 'version less or equal'; + public const VERSION_GREATER_THAN = 'version greater'; + public const VERSION_GREATER_THAN_EQUALS = 'version greater or equal'; + public const SET_IS = 'set is'; + public const SET_IS_NOT = 'set is not'; + public const SET_CONTAINS = 'set contains'; + public const SET_DOES_NOT_CONTAIN = 'set does not contain'; + public const SET_CONTAINS_ANY = 'set contains any'; + public const SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any'; + public const REGEX_MATCH = 'regex match'; + public const REGEX_DOES_NOT_MATCH = 'regex does not match'; } diff --git a/src/EvaluationCore/Types/EvaluationAllocation.php b/src/EvaluationCore/Types/EvaluationAllocation.php new file mode 100644 index 0000000..54731cf --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationAllocation.php @@ -0,0 +1,25 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationAllocation +{ + /** @var array<int> */ + public array $range; + + /** @var EvaluationDistribution[] */ + public array $distributions; + + /** + * @param array<int> $range + * @param EvaluationDistribution[] $distributions + */ + public function __construct( + array $range, + array $distributions + ) { + $this->range = $range; + $this->distributions = $distributions; + } +} diff --git a/src/EvaluationCore/Types/EvaluationBucket.php b/src/EvaluationCore/Types/EvaluationBucket.php new file mode 100644 index 0000000..2daeb6b --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationBucket.php @@ -0,0 +1,31 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationBucket +{ + /** @var array<string> */ + public array $selector; + + /** @var string */ + public string $salt; + + /** @var EvaluationAllocation[] */ + public array $allocations; + + /** + * @param array<string> $selector + * @param string $salt + * @param EvaluationAllocation[] $allocations + */ + public function __construct( + array $selector, + string $salt, + array $allocations + ) { + $this->selector = $selector; + $this->salt = $salt; + $this->allocations = $allocations; + } +} diff --git a/src/EvaluationCore/Types/EvaluationCondition.php b/src/EvaluationCore/Types/EvaluationCondition.php new file mode 100644 index 0000000..3a3e0a9 --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationCondition.php @@ -0,0 +1,31 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationCondition +{ + /** @var array<string> */ + public array $selector; + + /** @var string */ + public string $op; + + /** @var array<string> */ + public array $values; + + /** + * @param array<string> $selector + * @param string $op + * @param array<string> $values + */ + public function __construct( + array $selector, + string $op, + array $values + ) { + $this->selector = $selector; + $this->op = $op; + $this->values = $values; + } +} diff --git a/src/EvaluationCore/Types/EvaluationDistribution.php b/src/EvaluationCore/Types/EvaluationDistribution.php new file mode 100644 index 0000000..c12691a --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationDistribution.php @@ -0,0 +1,25 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationDistribution +{ + /** @var string */ + public string $variant; + + /** @var array<int> */ + public array $range; + + /** + * @param string $variant + * @param array<int> $range + */ + public function __construct( + string $variant, + array $range + ) { + $this->variant = $variant; + $this->range = $range; + } +} diff --git a/src/EvaluationCore/Types/EvaluationFlag.php b/src/EvaluationCore/Types/EvaluationFlag.php new file mode 100644 index 0000000..41406d5 --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationFlag.php @@ -0,0 +1,43 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationFlag +{ + /** @var array|null */ + public ?array $metadata = null; + + /** @var array<string, EvaluationVariant> */ + public array $variants; + + /** @var string */ + public string $key; + + /** @var EvaluationSegment[] */ + public array $segments; + + /** @var array|null */ + public ?array $dependencies = null; + + /** + * @param string $key + * @param array<string, EvaluationVariant> $variants + * @param EvaluationSegment[] $segments + * @param array<string>|null $dependencies + * @param array<string, mixed>|null $metadata + */ + public function __construct( + string $key, + array $variants, + array $segments, + ?array $dependencies = null, + ?array $metadata = null + ) { + $this->dependencies = $dependencies; + $this->segments = $segments; + $this->key = $key; + $this->variants = $variants; + $this->metadata = $metadata; + } +} diff --git a/src/EvaluationCore/Types/EvaluationSegment.php b/src/EvaluationCore/Types/EvaluationSegment.php new file mode 100644 index 0000000..186d0d5 --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationSegment.php @@ -0,0 +1,37 @@ +<?php +declare(strict_types=1); + +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationSegment +{ + /** @var EvaluationBucket|null */ + public ?EvaluationBucket $bucket; + + /** @var array<array<EvaluationCondition>>|null */ + public ?array $conditions; + + /** @var string|null */ + public ?string $variant; + + /** @var array|null */ + public ?array $metadata; + + /** + * @param EvaluationBucket|null $bucket + * @param array<array<EvaluationCondition>>|null $conditions + * @param string|null $variant + * @param array<string, mixed>|null $metadata + */ + public function __construct( + ?EvaluationBucket $bucket = null, + ?array $conditions = null, + ?string $variant = null, + ?array $metadata = null + ) { + $this->bucket = $bucket; + $this->conditions = $conditions; + $this->variant = $variant; + $this->metadata = $metadata; + } +} diff --git a/src/EvaluationCore/Types/EvaluationVariant.php b/src/EvaluationCore/Types/EvaluationVariant.php new file mode 100644 index 0000000..d0f2b5b --- /dev/null +++ b/src/EvaluationCore/Types/EvaluationVariant.php @@ -0,0 +1,45 @@ +<?php +namespace AmplitudeExperiment\EvaluationCore\Types; + +class EvaluationVariant { + public ?string $key; + public $value; + public $payload; + public ?array $metadata; + + public function __construct(?string $key = null, $value = null, $payload = null, ?array $metadata = null) { + $this->key = $key; + $this->value = $value; + $this->payload = $payload; + $this->metadata = $metadata; + } + + /** + * Creates an EvaluationVariant from a JSON response body + * + * @param array $data The decoded JSON data + * @return EvaluationVariant + */ + public static function fromJson(array $data): EvaluationVariant { + return new self( + $data['key'] ?? null, + $data['value'] ?? null, + $data['payload'] ?? null, + $data['metadata'] ?? null + ); + } + + /** + * Creates an array of EvaluationVariants from evaluation results + * + * @param array $results The evaluation results from response body + * @return array<string, EvaluationVariant> + */ + public static function fromEvaluationResults(array $results): array { + $variants = []; + foreach ($results as $flagKey => $variantData) { + $variants[$flagKey] = self::fromJson($variantData); + } + return $variants; + } +} diff --git a/src/EvaluationCore/Util.php b/src/EvaluationCore/Util.php index a363fc8..da46f40 100644 --- a/src/EvaluationCore/Util.php +++ b/src/EvaluationCore/Util.php @@ -2,6 +2,7 @@ namespace AmplitudeExperiment\EvaluationCore; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; use Exception; function select($selectable, $selector) @@ -11,7 +12,16 @@ function select($selectable, $selector) } foreach ($selector as $selectorElement) { - if (!$selectorElement || !$selectable || !is_array($selectable)) { + if ($selectable instanceof Types\EvaluationVariant) { + $selectable = [ + 'key' => $selectable->key, + 'value' => $selectable->value, + 'payload' => $selectable->payload, + 'metadata' => $selectable->metadata + ]; + } + + if (!is_bool($selectable) && (!$selectable || !is_array($selectable))) { return null; } @@ -23,7 +33,8 @@ function select($selectable, $selector) } // "0" is falsy in PHP, so we need to check for it explicitly - if (!$selectable && $selectable !== '0') { + // Also handle boolean values explicitly + if ((!$selectable && $selectable !== '0' && $selectable !== 0 && $selectable !== false) || $selectable === null) { return null; } else { return $selectable; @@ -31,19 +42,33 @@ function select($selectable, $selector) } /** + * @param array<string, EvaluationFlag> $flags + * @param string[]|null $flagKeys + * @return EvaluationFlag[] * @throws Exception */ -function topologicalSort($flags, $flagKeys = null): array +function topologicalSort(array $flags, ?array $flagKeys = null): array { - $available = $flags; + $available = []; + // Index flags by key for lookup + foreach ($flags as $flag) { + $available[$flag->key] = $flag; + } + $result = []; - $isNullOrEmpty = !$flagKeys || count($flagKeys) === 0; - $startingKeys = $isNullOrEmpty ? array_keys($available) : $flagKeys; + $startingKeys = $flagKeys ?? array_keys($available); + + if (empty($startingKeys)) { + return array_values($flags); + } foreach ($startingKeys as $flagKey) { + if (!array_key_exists($flagKey, $available)) { + continue; + } $traversal = parentTraversal($flagKey, $available); - if ($traversal) { - $result = array_merge($result, $traversal); + if ($traversal !== null) { + array_push($result, ...$traversal); } } @@ -51,35 +76,41 @@ function topologicalSort($flags, $flagKeys = null): array } /** + * @param string $flagKey + * @param array<string, EvaluationFlag> $available + * @param string[] $path + * @return EvaluationFlag[]|null * @throws Exception */ -function parentTraversal($flagKey, &$available, $path = []): ?array +function parentTraversal(string $flagKey, array &$available, array $path = []): ?array { $flag = $available[$flagKey] ?? null; - if (!$flag) { return null; - } elseif (empty($flag["dependencies"])) { - unset($available[$flag["key"]]); + } + + if (!$flag->dependencies || empty($flag->dependencies)) { + unset($available[$flag->key]); return [$flag]; } - $path[] = $flag["key"]; + $path[] = $flag->key; $result = []; - foreach ($flag["dependencies"] as $parentKey) { + foreach ($flag->dependencies as $parentKey) { if (in_array($parentKey, $path)) { throw new Exception("Detected a cycle between flags " . implode(',', $path)); } - - $traversal = parentTraversal($parentKey, $available, $path); - if ($traversal) { - $result = array_merge($result, $traversal); + if (array_key_exists($parentKey, $available)) { + $traversal = parentTraversal($parentKey, $available, $path); + if ($traversal !== null) { + array_push($result, ...$traversal); + } } } + $result[] = $flag; array_pop($path); - unset($available[$flag["key"]]); + unset($available[$flag->key]); return $result; } - diff --git a/src/Flag/FlagConfigService.php b/src/Flag/FlagConfigService.php index 30b00de..05f5ddb 100644 --- a/src/Flag/FlagConfigService.php +++ b/src/Flag/FlagConfigService.php @@ -2,9 +2,13 @@ namespace AmplitudeExperiment\Flag; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; +use Exception; use Psr\Http\Client\ClientExceptionInterface; use Psr\Log\LoggerInterface; +require_once __DIR__ . '/Util.php'; + class FlagConfigService { private LoggerInterface $logger; @@ -15,6 +19,11 @@ class FlagConfigService */ public array $cache; + /** + * @var EvaluationFlag[] + */ + private array $translatedFlags = []; + /** * @param array<string, mixed> $bootstrap */ @@ -23,6 +32,7 @@ public function __construct(FlagConfigFetcher $fetcher, LoggerInterface $logger, $this->fetcher = $fetcher; $this->logger = $logger; $this->cache = $bootstrap; + $this->translateFlags(); } public function refresh(): void @@ -31,6 +41,7 @@ public function refresh(): void try { $flagConfigs = $this->fetcher->fetch(); $this->cache = $flagConfigs; + $this->translateFlags(); } catch (ClientExceptionInterface $error) { $this->logger->error('[Experiment] Failed to fetch flag configs: ' . $error->getMessage()); } @@ -43,4 +54,25 @@ public function getFlagConfigs(): array { return $this->cache; } + + /** + * @return EvaluationFlag[] + */ + public function getTranslatedFlags(): array + { + return $this->translatedFlags; + } + + /** + * Translates raw flag configs into typed EvaluationFlag objects + */ + private function translateFlags(): void + { + try { + $this->translatedFlags = createFlagsFromArray($this->cache); + } catch (Exception $e) { + $this->logger->error('[Experiment] Failed to translate flag configs: ' . $e->getMessage()); + $this->translatedFlags = []; + } + } } diff --git a/src/Flag/Util.php b/src/Flag/Util.php new file mode 100644 index 0000000..5c29122 --- /dev/null +++ b/src/Flag/Util.php @@ -0,0 +1,124 @@ +<?php + +namespace AmplitudeExperiment\Flag; + +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationVariant; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationSegment; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationBucket; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationCondition; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationDistribution; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationAllocation; + +/** + * Creates an array of EvaluationFlag objects from raw flag data + * + * @param array<string, mixed> $rawFlags + * @return EvaluationFlag[] + */ +function createFlagsFromArray(array $rawFlags): array +{ + $flags = []; + + foreach ($rawFlags as $flagData) { + if (!isset($flagData['key'])) { + continue; + } + + // Process variants + $variants = []; + if (isset($flagData['variants']) && is_array($flagData['variants'])) { + foreach ($flagData['variants'] as $variantKey => $variantData) { + if (!isset($variantData['key'])) { + continue; + } + $variants[$variantKey] = new EvaluationVariant( + $variantData['key'], + $variantData['value'] ?? null, + $variantData['payload'] ?? null, + $variantData['metadata'] ?? null + ); + } + } + + // Process segments + $segments = []; + if (isset($flagData['segments']) && is_array($flagData['segments'])) { + foreach ($flagData['segments'] as $segmentData) { + // Process bucket if exists + $bucket = null; + if (isset($segmentData['bucket']) && is_array($segmentData['bucket'])) { + $allocations = []; + if (isset($segmentData['bucket']['allocations']) && is_array($segmentData['bucket']['allocations'])) { + foreach ($segmentData['bucket']['allocations'] as $allocationData) { + if (!isset($allocationData['distributions'], $allocationData['range'])) { + continue; + } + + $distributions = []; + foreach ($allocationData['distributions'] as $distributionData) { + if (!isset($distributionData['variant'], $distributionData['range'])) { + continue; + } + $distributions[] = new EvaluationDistribution( + $distributionData['variant'], + $distributionData['range'] + ); + } + + $allocations[] = new EvaluationAllocation( + $allocationData['range'], + $distributions + ); + } + } + + $bucket = new EvaluationBucket( + $segmentData['bucket']['selector'] ?? [], + $segmentData['bucket']['salt'] ?? '', + $allocations + ); + } + + // Process conditions if exists + $conditions = null; + if (isset($segmentData['conditions']) && is_array($segmentData['conditions'])) { + $conditions = array_map(function ($conditionSet) { + return array_map(function ($condition) { + if (!isset($condition['op'], $condition['selector'], $condition['values'])) { + return null; + } + return new EvaluationCondition( + $condition['selector'], + $condition['op'], + $condition['values'] + ); + }, $conditionSet); + }, $segmentData['conditions']); + + // Remove null values from conditions + $conditions = array_map(function($conditionSet) { + return array_filter($conditionSet); + }, array_filter($conditions)); + } + + $segments[] = new EvaluationSegment( + $bucket, + $conditions, + $segmentData['variant'] ?? null, + $segmentData['metadata'] ?? null + ); + } + } + + $flags[] = new EvaluationFlag( + $flagData['key'], + $variants, + $segments, + $flagData['dependencies'] ?? null, + $flagData['metadata'] ?? null + ); + } + + return $flags; +} diff --git a/src/Local/LocalEvaluationClient.php b/src/Local/LocalEvaluationClient.php index b40bbe7..01e745e 100644 --- a/src/Local/LocalEvaluationClient.php +++ b/src/Local/LocalEvaluationClient.php @@ -5,6 +5,7 @@ use AmplitudeExperiment\Assignment\AssignmentConfig; use AmplitudeExperiment\Assignment\AssignmentService; use AmplitudeExperiment\EvaluationCore\EvaluationEngine; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; use AmplitudeExperiment\Flag\FlagConfigFetcher; use AmplitudeExperiment\Flag\FlagConfigService; use AmplitudeExperiment\Http\GuzzleHttpClient; @@ -58,22 +59,38 @@ public function refreshFlagConfigs(): void * @param User $user The user to evaluate * @param array<string> $flagKeys The flags to evaluate with the user. If empty, all flags * from the flag cache are evaluated. - * @return array<Variant> evaluated variants + * @return array<string, Variant> evaluated variants */ public function evaluate(User $user, array $flagKeys = []): array { - $flags = $this->flagConfigService->getFlagConfigs(); + // Get translated flags from the flag config service + $flags = $this->flagConfigService->getTranslatedFlags(); + try { + // Sort flags topologically based on dependencies $flags = topologicalSort($flags, $flagKeys); } catch (\Exception $e) { $this->logger->error('[Experiment] Evaluate - error sorting flags: ' . $e->getMessage()); } - $this->logger->debug('[Experiment] Evaluate - user: ' . json_encode($user->toArray()) . ' with flags: ' . json_encode($flags)); - $results = array_map('AmplitudeExperiment\Variant::convertEvaluationVariantToVariant', $this->evaluation->evaluate($user->toEvaluationContext(), $flags)); + + $this->logger->debug('[Experiment] Evaluate - user: ' . json_encode($user->toArray()) . ' with flags: ' . json_encode(array_map(function($flag) { return $flag->key; }, $flags))); + + // Evaluate the user against the flags + $evaluationResults = $this->evaluation->evaluate($user->toEvaluationContext(), $flags); + + // Convert evaluation results to Variant objects + $results = []; + foreach ($evaluationResults as $key => $evaluationVariant) { + $results[$key] = Variant::convertEvaluationVariantToVariant($evaluationVariant); + } + $this->logger->debug('[Experiment] Evaluate - variants:' . json_encode($results)); + + // Track assignments if assignment service is configured if ($this->assignmentService) { $this->assignmentService->track($this->assignmentService->createAssignment($user, $results)); } + return $results; } diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index 68ec426..addb385 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -2,6 +2,7 @@ namespace AmplitudeExperiment\Remote; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationVariant; use AmplitudeExperiment\Http\HttpClientInterface; use AmplitudeExperiment\Http\GuzzleHttpClient; use AmplitudeExperiment\Logger\DefaultLogger; @@ -89,11 +90,10 @@ public function fetch(User $user, array $flagKeys = []): array return []; } - $results = json_decode($response->getBody(), true); - $variants = []; - foreach ($results as $flagKey => $flagResult) { - $variants[$flagKey] = Variant::convertEvaluationVariantToVariant($flagResult); - } + $results = EvaluationVariant::fromEvaluationResults(json_decode($response->getBody(), true)); + $variants = array_map(function ($flagResult) { + return Variant::convertEvaluationVariantToVariant($flagResult); + }, $results); $this->logger->debug('[Experiment] Fetched variants: ' . $response->getBody()); return $variants; } catch (ClientExceptionInterface $e) { diff --git a/src/Variant.php b/src/Variant.php index 8486ffd..7ca1892 100644 --- a/src/Variant.php +++ b/src/Variant.php @@ -2,6 +2,8 @@ namespace AmplitudeExperiment; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationVariant; + class Variant { /** @@ -48,41 +50,39 @@ public function __construct( } /** - * @param array<mixed> $evaluationVariant + * Converts an EvaluationVariant to a Variant + * + * @param EvaluationVariant $evaluationVariant The evaluation variant to convert + * @return Variant The converted variant */ - public static function convertEvaluationVariantToVariant(array $evaluationVariant): Variant + public static function convertEvaluationVariantToVariant(EvaluationVariant $evaluationVariant): Variant { - $variant = new Variant(); - if (empty($evaluationVariant)) { - return $variant; - } - $experimentKey = null; - if (isset($evaluationVariant['metadata'])) { - $experimentKey = $evaluationVariant['metadata']['experimentKey'] ?? null; + if (isset($evaluationVariant->metadata)) { + $experimentKey = $evaluationVariant->metadata['experimentKey'] ?? null; } - if (isset($evaluationVariant['key'])) { - $variant->key = $evaluationVariant['key']; + if ($evaluationVariant->key !== null) { + $variant->key = $evaluationVariant->key; } - if (isset($evaluationVariant['value'])) { - $variant->value = (string)$evaluationVariant['value']; + if ($evaluationVariant->value !== null) { + $variant->value = (string)$evaluationVariant->value; } - if (isset($evaluationVariant['payload'])) { - $variant->payload = $evaluationVariant['payload']; + if ($evaluationVariant->payload !== null) { + $variant->payload = $evaluationVariant->payload; } - if ($experimentKey) { + if ($experimentKey !== null) { $variant->expKey = $experimentKey; } - if (isset($evaluationVariant['metadata'])) { - $variant->metadata = $evaluationVariant['metadata']; + if (isset($evaluationVariant->metadata)) { + $variant->metadata = $evaluationVariant->metadata; } return $variant; diff --git a/tests/EvaluationCore/EvaluateIntegrationTest.php b/tests/EvaluationCore/EvaluateIntegrationTest.php index 92fd5d1..acadd6c 100644 --- a/tests/EvaluationCore/EvaluateIntegrationTest.php +++ b/tests/EvaluationCore/EvaluateIntegrationTest.php @@ -3,15 +3,24 @@ namespace AmplitudeExperiment\Test\EvaluationCore; use AmplitudeExperiment\EvaluationCore\EvaluationEngine; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; +use AmplitudeExperiment\Variant; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use PHPUnit\Framework\TestCase; +require_once __DIR__ . '/../../src/Flag/Util.php'; +use function AmplitudeExperiment\Flag\createFlagsFromArray; + class EvaluateIntegrationTest extends TestCase { private EvaluationEngine $engine; - private $flags; + + /** + * @var EvaluationFlag[] + */ + private array $flags; /** * @throws GuzzleException @@ -19,106 +28,133 @@ class EvaluateIntegrationTest extends TestCase protected function setUp(): void { $this->engine = new EvaluationEngine(); - $this->flags = $this->getFlags('server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy'); + $rawFlags = $this->getFlags('server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy'); + $this->flags = createFlagsFromArray($rawFlags); } public function testOff() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-off']; - $this->assertEquals('off', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-off']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); } public function testOn() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-on']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-on']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testIndividualInclusionsMatchUserId() { $user = $this->userContext('user_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-individual-inclusions']; - $this->assertEquals('on', $result['key']); - $this->assertEquals('individual-inclusions', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-individual-inclusions']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); + $this->assertEquals('individual-inclusions', $variant->metadata['segmentName']); } public function testIndividualInclusionsMatchDeviceId() { $user = $this->userContext(null, 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-individual-inclusions']; - $this->assertEquals('on', $result['key']); - $this->assertEquals('individual-inclusions', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-individual-inclusions']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); + $this->assertEquals('individual-inclusions', $variant->metadata['segmentName']); } public function testIndividualInclusionsNoMatchUserId() { $user = $this->userContext('not_user_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-individual-inclusions']; - $this->assertEquals('off', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-individual-inclusions']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); } public function testIndividualInclusionsNoMatchDeviceId() { $user = $this->userContext(null, 'not_device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-individual-inclusions']; - $this->assertEquals('off', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-individual-inclusions']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); } public function testFlagDependenciesOn() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-flag-dependencies-on']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-flag-dependencies-on']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testFlagDependenciesOff() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-flag-dependencies-off']; - $this->assertEquals('off', $result['key']); - $this->assertEquals('flag-dependencies', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-flag-dependencies-off']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); + $this->assertEquals('flag-dependencies', $variant->metadata['segmentName']); } public function testStickyBucketingOn() { $user = $this->userContext('user_id', 'device_id', null, ['[Experiment] test-sticky-bucketing' => 'on']); - $result = $this->engine->evaluate($user, $this->flags)['test-sticky-bucketing']; - $this->assertEquals('on', $result['key']); - $this->assertEquals('sticky-bucketing', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-sticky-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); + $this->assertEquals('sticky-bucketing', $variant->metadata['segmentName']); } public function testStickyBucketingOff() { $user = $this->userContext('user_id', 'device_id', null, ['[Experiment] test-sticky-bucketing' => 'off']); - $result = $this->engine->evaluate($user, $this->flags)['test-sticky-bucketing']; - $this->assertEquals('off', $result['key']); - $this->assertEquals('All Other Users', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-sticky-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); + $this->assertEquals('All Other Users', $variant->metadata['segmentName']); } public function testStickyBucketingNonVariant() { $user = $this->userContext('user_id', 'device_id', null, ['[Experiment] test-sticky-bucketing' => 'not-a-variant']); - $result = $this->engine->evaluate($user, $this->flags)['test-sticky-bucketing']; - $this->assertEquals('off', $result['key']); - $this->assertEquals('All Other Users', $result['metadata']['segmentName']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-sticky-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); + $this->assertEquals('All Other Users', $variant->metadata['segmentName']); } public function testExperiment() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-experiment']; - $this->assertEquals('on', $result['key']); - $this->assertEquals('exp-1', $result['metadata']['experimentKey']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-experiment']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); + $this->assertEquals('exp-1', $variant->metadata['experimentKey']); } public function testFlag() { $user = $this->userContext('user_id', 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-flag']; - $this->assertEquals('on', $result['key']); - $this->assertArrayNotHasKey('experimentKey', $result['metadata']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-flag']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); + $this->assertArrayNotHasKey('experimentKey', $variant->metadata); } public function testMultipleConditionsAndValuesAllMatch() @@ -128,8 +164,10 @@ public function testMultipleConditionsAndValuesAllMatch() 'key-2' => 'value-2', 'key-3' => 'value-3', ]); - $result = $this->engine->evaluate($user, $this->flags)['test-multiple-conditions-and-values']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-multiple-conditions-and-values']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testMultipleConditionsAndValuesSomeMatch() @@ -138,85 +176,109 @@ public function testMultipleConditionsAndValuesSomeMatch() 'key-1' => 'value-1', 'key-2' => 'value-2', ]); - $result = $this->engine->evaluate($user, $this->flags)['test-multiple-conditions-and-values']; - $this->assertEquals('off', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-multiple-conditions-and-values']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); } public function testAmplitudePropertyTargeting() { $user = $this->userContext('user_id', 'device_id', null, ['key-1' => 'value-1']); - $result = $this->engine->evaluate($user, $this->flags)['test-amplitude-property-targeting']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-amplitude-property-targeting']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testCohortTargetingOn() { $user = $this->userContext(null, null, null, null, ['u0qtvwla', '12345678']); - $result = $this->engine->evaluate($user, $this->flags)['test-cohort-targeting']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-cohort-targeting']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testCohortTargetingOff() { $user = $this->userContext(null, null, null, null, ['12345678', '87654321']); - $result = $this->engine->evaluate($user, $this->flags)['test-cohort-targeting']; - $this->assertEquals('off', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-cohort-targeting']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('off', $variant->key); } public function testGroupNameTargeting() { $user = $this->groupContext('org name', 'amplitude'); - $result = $this->engine->evaluate($user, $this->flags)['test-group-name-targeting']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-group-name-targeting']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGroupPropertyTargeting() { $user = $this->groupContext('org name', 'amplitude', ['org plan' => 'enterprise2']); - $result = $this->engine->evaluate($user, $this->flags)['test-group-property-targeting']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-group-property-targeting']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testAmplitudeIdBucketing() { $user = $this->userContext(null, null, '1234567890'); - $result = $this->engine->evaluate($user, $this->flags)['test-amplitude-id-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-amplitude-id-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testUserIdBucketing() { $user = $this->userContext('user_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-user-id-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-user-id-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testDeviceIdBucketing() { $user = $this->userContext(null, 'device_id'); - $result = $this->engine->evaluate($user, $this->flags)['test-device-id-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-device-id-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testCustomUserPropertyBucketing() { $user = $this->userContext(null, null, null, ['key' => 'value']); - $result = $this->engine->evaluate($user, $this->flags)['test-custom-user-property-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-custom-user-property-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGroupNameBucketing() { $user = $this->groupContext('org name', 'amplitude'); - $result = $this->engine->evaluate($user, $this->flags)['test-group-name-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-group-name-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGroupPropertyBucketing() { $user = $this->groupContext('org name', 'amplitude', ['org plan' => 'enterprise2']); - $result = $this->engine->evaluate($user, $this->flags)['test-group-name-bucketing']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-group-name-bucketing']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testOnePercentAllocation() @@ -224,8 +286,10 @@ public function testOnePercentAllocation() $on = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-1-percent-allocation']; - if ($result['key'] === 'on') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-1-percent-allocation']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'on') { $on++; } } @@ -237,8 +301,10 @@ public function testFiftyPercentAllocation() $on = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-50-percent-allocation']; - if ($result['key'] === 'on') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-50-percent-allocation']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'on') { $on++; } } @@ -250,8 +316,10 @@ public function testNinetyNinePercentAllocation() $on = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-99-percent-allocation']; - if ($result['key'] === 'on') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-99-percent-allocation']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'on') { $on++; } } @@ -264,10 +332,12 @@ public function testOnePercentDistribution() $treatment = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-1-percent-distribution']; - if ($result['key'] === 'control') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-1-percent-distribution']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'control') { $control++; - } elseif ($result['key'] === 'treatment') { + } elseif ($variant->key === 'treatment') { $treatment++; } } @@ -281,10 +351,12 @@ public function testFiftyPercentDistribution() $treatment = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-50-percent-distribution']; - if ($result['key'] === 'control') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-50-percent-distribution']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'control') { $control++; - } elseif ($result['key'] === 'treatment') { + } elseif ($variant->key === 'treatment') { $treatment++; } } @@ -298,10 +370,12 @@ public function testNinetyNinePercentDistribution() $treatment = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-99-percent-distribution']; - if ($result['key'] === 'control') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-99-percent-distribution']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'control') { $control++; - } elseif ($result['key'] === 'treatment') { + } elseif ($variant->key === 'treatment') { $treatment++; } } @@ -317,14 +391,16 @@ public function testMultipleDistributions() $d = 0; for ($i = 0; $i < 10000; $i++) { $user = $this->userContext(null, (string)($i + 1)); - $result = $this->engine->evaluate($user, $this->flags)['test-multiple-distributions']; - if ($result['key'] === 'a') { + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-multiple-distributions']; + $variant = Variant::convertEvaluationVariantToVariant($result); + if ($variant->key === 'a') { $a++; - } elseif ($result['key'] === 'b') { + } elseif ($variant->key === 'b') { $b++; - } elseif ($result['key'] === 'c') { + } elseif ($variant->key === 'c') { $c++; - } elseif ($result['key'] === 'd') { + } elseif ($variant->key === 'd') { $d++; } } @@ -337,154 +413,200 @@ public function testMultipleDistributions() public function testIs() { $user = $this->userContext(null, null, null, ['key' => 'value']); - $result = $this->engine->evaluate($user, $this->flags)['test-is']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-is']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testIsNot() { $user = $this->userContext(null, null, null, ['key' => 'value']); - $result = $this->engine->evaluate($user, $this->flags)['test-is-not']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-is-not']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testContains() { $user = $this->userContext(null, null, null, ['key' => 'value']); - $result = $this->engine->evaluate($user, $this->flags)['test-contains']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-contains']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testDoesNotContain() { $user = $this->userContext(null, null, null, ['key' => 'value']); - $result = $this->engine->evaluate($user, $this->flags)['test-does-not-contain']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-does-not-contain']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testLess() { $user = $this->userContext(null, null, null, ['key' => '-1']); - $result = $this->engine->evaluate($user, $this->flags)['test-less']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-less']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testLessOrEqual() { $user = $this->userContext(null, null, null, ['key' => '0']); - $result = $this->engine->evaluate($user, $this->flags)['test-less-or-equal']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-less-or-equal']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGreater() { $user = $this->userContext(null, null, null, ['key' => '1']); - $result = $this->engine->evaluate($user, $this->flags)['test-greater']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-greater']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGreaterOrEqual() { $user = $this->userContext(null, null, null, ['key' => '0']); - $result = $this->engine->evaluate($user, $this->flags)['test-greater-or-equal']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-greater-or-equal']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testVersionLess() { $user = $this->freeformUserContext(['version' => '1.9.0']); - $result = $this->engine->evaluate($user, $this->flags)['test-version-less']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-version-less']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testVersionLessOrEqual() { $user = $this->freeformUserContext(['version' => '1.10.0']); - $result = $this->engine->evaluate($user, $this->flags)['test-version-less-or-equal']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-version-less-or-equal']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testVersionGreater() { $user = $this->freeformUserContext(['version' => '1.10.0']); - $result = $this->engine->evaluate($user, $this->flags)['test-version-greater']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-version-greater']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testVersionGreaterOrEqual() { $user = $this->freeformUserContext(['version' => '1.9.0']); - $result = $this->engine->evaluate($user, $this->flags)['test-version-greater-or-equal']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-version-greater-or-equal']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetIs() { $user = $this->userContext(null, null, null, ['key' => ['1', '2', '3']]); - $result = $this->engine->evaluate($user, $this->flags)['test-set-is']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-is']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetIsNot() { $user = $this->userContext(null, null, null, ['key' => ['1', '2']]); - $result = $this->engine->evaluate($user, $this->flags)['test-set-is-not']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-is-not']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetContains() { $user = $this->userContext(null, null, null, ['key' => ['1', '2', '3', '4']]); - $result = $this->engine->evaluate($user, $this->flags)['test-set-contains']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-contains']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetDoesNotContain() { $user = $this->userContext(null, null, null, ['key' => ['1', '2', '4']]); - $result = $this->engine->evaluate($user, $this->flags)['test-set-does-not-contain']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-does-not-contain']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetContainsAny() { $user = $this->userContext(null, null, null, null, ['u0qtvwla', '12345678']); - $result = $this->engine->evaluate($user, $this->flags)['test-set-contains-any']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-contains-any']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testSetDoesNotContainAny() { $user = $this->userContext(null, null, null, null, ['12345678', '87654321']); - $result = $this->engine->evaluate($user, $this->flags)['test-set-does-not-contain-any']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-set-does-not-contain-any']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGlobMatch() { $user = $this->userContext(null, null, null, ['key' => '/path/1/2/3/end']); - $result = $this->engine->evaluate($user, $this->flags)['test-glob-match']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-glob-match']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testGlobDoesNotMatch() { $user = $this->userContext(null, null, null, ['key' => '/path/1/2/3']); - $result = $this->engine->evaluate($user, $this->flags)['test-glob-does-not-match']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-glob-does-not-match']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } public function testIsWithBooleans() { $user = $this->userContext(null, null, null, ['true' => 'TRUE', 'false' => 'FALSE']); - $result = $this->engine->evaluate($user, $this->flags)['test-is-with-booleans']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-is-with-booleans']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); $user = $this->userContext(null, null, null, ['true' => 'True', 'false' => 'False']); - $result = $this->engine->evaluate($user, $this->flags)['test-is-with-booleans']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-is-with-booleans']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); $user = $this->userContext(null, null, null, ['true' => 'true', 'false' => 'false']); - $result = $this->engine->evaluate($user, $this->flags)['test-is-with-booleans']; - $this->assertEquals('on', $result['key']); + $results = $this->engine->evaluate($user, $this->flags); + $result = $results['test-is-with-booleans']; + $variant = Variant::convertEvaluationVariantToVariant($result); + $this->assertEquals('on', $variant->key); } private function userContext($userId = null, $deviceId = null, $amplitudeId = null, $userProperties = [], $cohortIds = []): array diff --git a/tests/EvaluationCore/EvaluationEngineTest.php b/tests/EvaluationCore/EvaluationEngineTest.php new file mode 100644 index 0000000..c2c5d4e --- /dev/null +++ b/tests/EvaluationCore/EvaluationEngineTest.php @@ -0,0 +1,137 @@ +<?php + +namespace AmplitudeExperiment\Test\EvaluationCore; + +use AmplitudeExperiment\EvaluationCore\EvaluationEngine; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationSegment; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationVariant; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationCondition; +use PHPUnit\Framework\TestCase; + +class EvaluationEngineTest extends TestCase +{ + private EvaluationEngine $engine; + + protected function setUp(): void + { + $this->engine = new EvaluationEngine(); + } + + public function testBooleanMatching() + { + $variants = [ + 'on' => new EvaluationVariant('on'), + 'off' => new EvaluationVariant('off') + ]; + + // Create test segments for different boolean conditions + $trueSegment = new EvaluationSegment( + null, + [[new EvaluationCondition( + ['context','user', 'user_properties', 'boolProp'], + 'is', + ['true'] + )]], + 'on' + ); + + $falseSegment = new EvaluationSegment( + null, + [[new EvaluationCondition( + ['context','user', 'user_properties', 'boolProp'], + 'is', + ['false'] + )]], + 'off' + ); + + $segments = [$trueSegment, $falseSegment]; + $flag = new EvaluationFlag('test-bool', $variants, $segments); + $flags = ['test-bool' => $flag]; + + // Test case 1: PHP boolean true + $context = ['user' => ['user_properties' => ['boolProp' => true]]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool']->key); + + // Test case 2: PHP boolean false + $context = ['user' => ['user_properties' => ['boolProp' => false]]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('off', $results['test-bool']->key); + + // Test case 3: String 'true' + $context = ['user' => ['user_properties' => ['boolProp' => 'true']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool']->key); + + // Test case 4: String 'false' + $context = ['user' => ['user_properties' => ['boolProp' => 'false']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('off', $results['test-bool']->key); + + // Test case 5: String 'True' (capitalized) + $context = ['user' => ['user_properties' => ['boolProp' => 'True']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool']->key); + + // Test case 6: String 'False' (capitalized) + $context = ['user' => ['user_properties' => ['boolProp' => 'False']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('off', $results['test-bool']->key); + + // Test case 7: Numeric 1 + $context = ['user' => ['user_properties' => ['boolProp' => 1]]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool']->key); + + // Test case 8: Numeric 0 + $context = ['user' => ['user_properties' => ['boolProp' => 0]]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('off', $results['test-bool']->key); + + // Test case 9: String '1' + $context = ['user' => ['user_properties' => ['boolProp' => '1']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool']->key); + + // Test case 10: String '0' + $context = ['user' => ['user_properties' => ['boolProp' => '0']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('off', $results['test-bool']->key); + } + + public function testBooleanIsNotMatching() + { + $variants = [ + 'on' => new EvaluationVariant('on'), + 'off' => new EvaluationVariant('off') + ]; + + $segment = new EvaluationSegment( + null, + [[new EvaluationCondition( + ['context','user', 'user_properties', 'boolProp'], + 'is not', + ['true'] + )]], + 'on' + ); + + $flag = new EvaluationFlag('test-bool-not', $variants, [$segment]); + $flags = ['test-bool-not' => $flag]; + + // Test negative cases + $context = ['user' => ['user_properties' => ['boolProp' => false]]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool-not']->key); + + $context = ['user' => ['user_properties' => ['boolProp' => 'false']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool-not']->key); + + $context = ['user' => ['user_properties' => ['boolProp' => 'False']]]; + $results = $this->engine->evaluate($context, $flags); + $this->assertEquals('on', $results['test-bool-not']->key); + } +} diff --git a/tests/EvaluationCore/TopologicalSortTest.php b/tests/EvaluationCore/TopologicalSortTest.php index d994aa8..40557f4 100644 --- a/tests/EvaluationCore/TopologicalSortTest.php +++ b/tests/EvaluationCore/TopologicalSortTest.php @@ -2,6 +2,7 @@ namespace AmplitudeExperiment\Test\EvaluationCore; +use AmplitudeExperiment\EvaluationCore\Types\EvaluationFlag; use Exception; use PHPUnit\Framework\TestCase; use function AmplitudeExperiment\EvaluationCore\topologicalSort; @@ -294,19 +295,19 @@ function testComplexNoCycleStartingWithRoot() private function topologicalSortInternal($flags, $flagKeys = null): array { $flagsMap = array_reduce($flags, function ($map, $flag) { - $map[$flag["key"]] = $flag; + $map[$flag->key] = $flag; return $map; }, []); return topologicalSort($flagsMap, $flagKeys); } - private function flag($key, $dependencies = null): array + private function flag($key, $dependencies = null): EvaluationFlag { - return [ - 'key' => strval($key), - 'variants' => [], - 'segments' => [], - 'dependencies' => is_array($dependencies) ? array_map('strval', $dependencies) : null, - ]; + $flag = new EvaluationFlag($key, [], []); + $flag->key = strval($key); + $flag->variants = []; + $flag->segments = []; + $flag->dependencies = is_array($dependencies) ? array_map('strval', $dependencies) : null; + return $flag; } }