Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.3.0
spec-ref: v0.7.0
tags: core or local-evaluation
language: php
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[submodule "tests/Engine/EngineTests/EngineTestData"]
path = tests/Engine/EngineTests/EngineTestData
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.13.2
[submodule "tests/Engine/EngineTests/SdkBenchmarks"]
path = tests/Engine/EngineTests/SdkBenchmarks
url = https://github.com/Flagsmith/sdk-benchmarks.git
Expand Down
8 changes: 8 additions & 0 deletions sdk-spec/steps.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ function createClient(string $environmentKey, string $apiUrl): Flagsmith
return new Flagsmith(apiKey: $environmentKey, host: $apiUrl);
}

// Create the SDK client with local evaluation
function createClientWithLocalEvaluation(string $environmentKey, string $apiUrl): Flagsmith
{
$flagsmith = new Flagsmith(apiKey: $environmentKey, host: $apiUrl, environmentTtl: 60);
$flagsmith->updateEnvironment();
return $flagsmith;
}

// When the application gets the environment flags
function getEnvironmentFlags(Flagsmith $flagsmith): array
{
Expand Down
183 changes: 162 additions & 21 deletions src/Engine/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Flagsmith\Engine;

use Flagsmith\Engine\Utils\Hashing;
use Flagsmith\Engine\Utils\LazyFlags;
use Flagsmith\Engine\Utils\Semver;
use Flagsmith\Engine\Utils\Types\Context\EvaluationContext;
use Flagsmith\Engine\Utils\Types\Context\FeatureContext;
Expand Down Expand Up @@ -46,8 +47,35 @@ public static function getEvaluationResult($context): EvaluationResult

$context = self::getEnrichedContext($context);

// Discard any caller-supplied `$.flags`: they are resolved lazily as
// segment conditions read them. Without segments, nothing reads them.
$resolvedFlags = null;
if (!empty($context->segments)) {
$context = clone $context;
$resolvedFlags = new LazyFlags($context);
$context->flags = $resolvedFlags;
}

/** @var array<string, true> Names of the features overridden by any segment */
$targetedFeatureNames = [];

$cycleHits = 0;

foreach ($context->segments as $segment) {
if (!self::isContextInSegment($context, $segment)) {
foreach ($segment->overrides as $overrideFeature) {
$targetedFeatureNames[$overrideFeature->name] = true;
}

$matches = self::isContextInSegment($context, $segment);

if ($resolvedFlags->getCycleHits() !== $cycleHits) {
// Evaluating this segment cut a cycle, so it matched, or failed to,
// on a flag that could not be resolved. Neither verdict is sound.
$cycleHits = $resolvedFlags->getCycleHits();
continue;
}

if (!$matches) {
continue;
}

Expand All @@ -58,14 +86,8 @@ public static function getEvaluationResult($context): EvaluationResult

foreach ($segment->overrides as $overrideFeature) {
$featureName = $overrideFeature->name;
$evaluatedFeature = $evaluatedFeatures[$featureName] ?? null;
if ($evaluatedFeature) {
$overrideWinsPriority =
($overrideFeature->priority ?? self::WEAKEST_PRIORITY) <
($evaluatedFeature->priority ?? self::WEAKEST_PRIORITY);
if (!$overrideWinsPriority) {
continue;
}
if (!self::overrideWins($overrideFeature, $evaluatedFeatures[$featureName] ?? null)) {
continue;
}

$evaluatedFeatures[$featureName] = $overrideFeature;
Expand All @@ -76,7 +98,7 @@ public static function getEvaluationResult($context): EvaluationResult
foreach ($context->features as $feature) {
$featureName = $feature->name;
$evaluatedFeature = $evaluatedFeatures[$featureName] ?? null;
$reason = 'DEFAULT';
$reason = self::getDefaultReason($featureName, $targetedFeatureNames);
if ($evaluatedFeature) {
$feature = $evaluatedFeature;
$reason = "TARGETING_MATCH; segment={$matchedSegmentsByFeatureName[$featureName]->name}";
Expand All @@ -89,6 +111,24 @@ public static function getEvaluationResult($context): EvaluationResult
);
}

if ($resolvedFlags !== null) {
$context->flags = null; // Frees both by reference counting, not by the cycle collector
}

if ($resolvedFlags?->isUsed()) {
// Only reached when a segment condition read a flag. Those results take
// precedence: they were resolved with the cycle guard, unlike the
// single-pass recomputation above.
foreach ($resolvedFlags->getResolved() as $featureName => $flag) {
$evaluatedFlags[$featureName] = $flag;
}
foreach ($resolvedFlags->getCyclic() as $featureName) {
if (isset($evaluatedFlags[$featureName])) {
$evaluatedFlags[$featureName]->reason = LazyFlags::CIRCULAR_DEPENDENCY_REASON;
}
}
}

$result = new EvaluationResult();
$result->flags = $evaluatedFlags;
$result->segments = $evaluatedSegments;
Expand All @@ -114,11 +154,44 @@ private static function getEnrichedContext(EvaluationContext $context): Evaluati
}

/**
* Whether a segment override takes precedence over the one held so far.
*
* Lower priority wins, and the first seen wins a tie, so that precedence
* follows context order rather than the order segments happen to be
* evaluated in.
* @internal
* @param FeatureContext $candidate
* @param ?FeatureContext $incumbent
* @return bool
*/
public static function overrideWins($candidate, $incumbent): bool
{
return $incumbent === null
|| ($candidate->priority ?? self::WEAKEST_PRIORITY) < ($incumbent->priority ?? self::WEAKEST_PRIORITY);
}

/**
* The reason for a flag served by its environment default.
*
* DEFAULT if any segment overrides the feature, though none matched, or
* STATIC if no segment overrides it.
* @internal
* @param string $featureName
* @param array<string, mixed> $targetedFeatureNames Keyed by the names of the features any segment overrides
* @return string
*/
public static function getDefaultReason($featureName, $targetedFeatureNames): string
{
return isset($targetedFeatureNames[$featureName]) ? 'DEFAULT' : 'STATIC';
}

/**
* @internal
* @param EvaluationContext $context
* @param SegmentContext $segment
* @return bool
*/
private static function isContextInSegment($context, $segment): bool
public static function isContextInSegment($context, $segment): bool
{
if (empty($segment->rules)) {
return false;
Expand All @@ -134,14 +207,20 @@ private static function isContextInSegment($context, $segment): bool
}

/**
* @internal
* @param FeatureContext $feature
* @param ?string $splitKey
* @param string $reason
* @return FlagResult
*/
private static function getFlagResultFromFeatureContext($feature, $splitKey, $reason = 'DEFAULT')
public static function getFlagResultFromFeatureContext($feature, $splitKey, $reason = 'DEFAULT')
{
$variantKey = null;

if ($splitKey !== null && !empty($feature->variants)) {
// Default to the control bucket; a matched named variant overrides this.
$variantKey = 'control';

$hashing = new Hashing();
$percentageValue = $hashing->getHashedPercentageForObjectIds([
$feature->key,
Expand All @@ -163,6 +242,7 @@ private static function getFlagResultFromFeatureContext($feature, $splitKey, $re
$flag->name = $feature->name;
$flag->enabled = $feature->enabled;
$flag->value = $variant->value;
$flag->variant = $variant->key;
$flag->reason = "SPLIT; weight={$variant->weight}";
$flag->metadata = $feature->metadata;
return $flag;
Expand All @@ -175,6 +255,7 @@ private static function getFlagResultFromFeatureContext($feature, $splitKey, $re
$flag->name = $feature->name;
$flag->enabled = $feature->enabled;
$flag->value = $feature->value;
$flag->variant = $variantKey;
$flag->reason = $reason;
$flag->metadata = $feature->metadata;
return $flag;
Expand Down Expand Up @@ -377,13 +458,18 @@ private static function _contextMatchesCondition(
return version_compare($contextValue, $actualVersion, $operator);
}

$conditionValue = $cast($condition->value);
if ($conditionValue === null) {
return false; // Not coercible to the context value type
}

return match ($operator) {
'==' => $contextValue === $cast($condition->value),
'!=' => $contextValue !== $cast($condition->value),
'>' => $contextValue > $cast($condition->value),
'>=' => $contextValue >= $cast($condition->value),
'<' => $contextValue < $cast($condition->value),
'<=' => $contextValue <= $cast($condition->value),
'==' => $contextValue === $conditionValue,
'!=' => $contextValue !== $conditionValue,
'>' => $contextValue > $conditionValue,
'>=' => $contextValue >= $conditionValue,
'<' => $contextValue < $conditionValue,
'<=' => $contextValue <= $conditionValue,
};
}

Expand All @@ -403,9 +489,15 @@ private static function _getContextValue($context, $property)
}

if (str_starts_with($property, '$.')) {
$memberNames = self::_getMemberNames($property);
if ($memberNames !== null) {
$value = self::_digMemberNames($context, $memberNames);
return in_array(gettype($value), self::VALID_CONTEXT_VALUE_TYPES) ? $value : null;
}

try {
$jsonpath = new JSONPath($context);
$results = $jsonpath->find($property)->getData();
$results = $jsonpath->find(self::_decodeUnicodeEscapes($property))->getData();
} catch (JSONPathException) {
return null;
}
Expand All @@ -422,6 +514,55 @@ private static function _getContextValue($context, $property)
return null;
}

// Decode runs of `\uXXXX` escapes, keeping escaped backslashes as written
private static function _decodeUnicodeEscapes(string $property): string
{
return preg_replace_callback(
'/\\\\\\\\|(?:\\\\u[0-9a-fA-F]{4})+/',
fn ($match) => $match[0] === '\\\\' ? $match[0] : json_decode("\"{$match[0]}\"") ?? $match[0],
$property,
);
}

/**
* The member names of a JSONPath made of member names only, e.g. `$.identity.identifier`, or null for any other path
* @param string $property
* @return ?array<string>
*/
private static function _getMemberNames($property): ?array
{
static $memberNamesByProperty = [];
if (!array_key_exists($property, $memberNamesByProperty)) {
$memberNamesByProperty[$property] = preg_match('/^\$(\.[\p{L}\p{N}_-]+)+$/u', $property)
? explode('.', substr($property, 2))
: null;
}
return $memberNamesByProperty[$property];
}

/**
* Read a context value by member names, as a JSONPath of member names only would
* @param EvaluationContext $context
* @param array<string> $memberNames
* @return ?mixed
*/
private static function _digMemberNames($context, $memberNames)
{
$value = $context;
foreach ($memberNames as $memberName) {
$value = match (true) {
is_array($value) => $value[$memberName] ?? null,
$value instanceof \ArrayAccess => $value[$memberName],
is_object($value) => $value->$memberName ?? null,
default => null,
};
if ($value === null) {
return null;
}
}
return $value;
}

/**
* Get a condition value type caster according to a context value
* @param mixed $contextValue
Expand All @@ -436,8 +577,8 @@ private static function _getCaster($contextValue): ?callable
return match (gettype($contextValue)) {
'boolean' => fn ($v) => !in_array($v, ['False', 'false']),
'string' => 'strval',
'integer' => fn ($v) => is_numeric($v) ? (int) $v : $v,
'double' => fn ($v) => is_numeric($v) ? (float) $v : $v,
'integer' => fn ($v) => filter_var($v, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
'double' => fn ($v) => filter_var($v, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE),
};
}
}
Loading
Loading