From 65ca15245e9c4d19b5311e285a3833fec6c0d2ad Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 5 Sep 2026 13:51:44 +0200 Subject: [PATCH] fix(flags): honor versioned local property matching --- .changeset/versioned-property-matching.md | 5 + api/public-api.json | 50 +++++ lib/Client.php | 56 ++++-- lib/FeatureFlag.php | 145 +++++++++++--- lib/FlagDefinitionCacheProvider.php | 8 +- test/FlagDefinitionCacheProviderTest.php | 219 ++++++++++++++++++++++ test/VersionedPropertyMatchingTest.php | 83 ++++++++ 7 files changed, 528 insertions(+), 38 deletions(-) create mode 100644 .changeset/versioned-property-matching.md create mode 100644 test/VersionedPropertyMatchingTest.php diff --git a/.changeset/versioned-property-matching.md b/.changeset/versioned-property-matching.md new file mode 100644 index 0000000..483fdf1 --- /dev/null +++ b/.changeset/versioned-property-matching.md @@ -0,0 +1,5 @@ +--- +posthog-php: patch +--- + +Honor the definitions snapshot's `property_matching_version` during local feature flag evaluation, including group, cohort, and flag dependency conditions. Version 2 uses explicit boolean matching; missing or other versions retain legacy matching. Preserve the selector across external definition caches and reloads. diff --git a/api/public-api.json b/api/public-api.json index d2c9f7d..d164eae 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -2136,6 +2136,16 @@ "default": null, "defaultConstant": null, "hasDefault": false + }, + { + "name": "propertyMatchingVersion", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": 1, + "defaultConstant": null, + "hasDefault": true } ] }, @@ -2204,6 +2214,16 @@ "default": null, "defaultConstant": null, "hasDefault": true + }, + { + "name": "propertyMatchingVersion", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": 1, + "defaultConstant": null, + "hasDefault": true } ] }, @@ -2302,6 +2322,16 @@ "default": [], "defaultConstant": null, "hasDefault": true + }, + { + "name": "propertyMatchingVersion", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": 1, + "defaultConstant": null, + "hasDefault": true } ] }, @@ -2330,6 +2360,16 @@ "default": null, "defaultConstant": null, "hasDefault": false + }, + { + "name": "propertyMatchingVersion", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": 1, + "defaultConstant": null, + "hasDefault": true } ] }, @@ -2398,6 +2438,16 @@ "default": null, "defaultConstant": null, "hasDefault": true + }, + { + "name": "propertyMatchingVersion", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": 1, + "defaultConstant": null, + "hasDefault": true } ] }, diff --git a/lib/Client.php b/lib/Client.php index 8f496b4..a2c0ab4 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -136,6 +136,9 @@ class Client implements FeatureFlagEvaluationsHost */ public $featureFlagsByKey; + /** @var mixed Matching selector from the current definition set; missing defaults to legacy. */ + private $propertyMatchingVersion = 1; + /** * @var SizeLimitedHash */ @@ -783,12 +786,14 @@ private function doGetFeatureFlagResult( $featureFlagError = null; $localFlagDefinition = null; - foreach ($this->featureFlags as $flag) { + $definitionSnapshot = $this->flagDefinitionSnapshot(); + foreach ($definitionSnapshot['flags'] as $flag) { if ($flag["key"] == $key) { $localFlagDefinition = $flag; try { $result = $this->computeFlagLocally( $flag, + $definitionSnapshot, $distinctId, $groups, $personProperties, @@ -994,11 +999,13 @@ public function getAllFlags( $response = []; $fallbackToFlags = false; - if (count($this->featureFlags) > 0) { - foreach ($this->featureFlags as $flag) { + $definitionSnapshot = $this->flagDefinitionSnapshot(); + if (count($definitionSnapshot['flags']) > 0) { + foreach ($definitionSnapshot['flags'] as $flag) { try { $response[$flag['key']] = $this->computeFlagLocally( $flag, + $definitionSnapshot, $distinctId, $groups, $personProperties, @@ -1095,10 +1102,11 @@ public function evaluateFlags( // Local pass: try to resolve any flag we can without going to the server. Track whether // any flag was inconclusive (which forces a remote round trip) so we can skip /flags // entirely when local evaluation covered everything we know about. - $hasLocalDefinitions = count($this->featureFlags) > 0; + $definitionSnapshot = $this->flagDefinitionSnapshot(); + $hasLocalDefinitions = count($definitionSnapshot['flags']) > 0; if ($hasLocalDefinitions) { $localKeys = []; - foreach ($this->featureFlags as $flag) { + foreach ($definitionSnapshot['flags'] as $flag) { $key = $flag['key'] ?? null; if (!is_string($key) || $key === '') { continue; @@ -1112,6 +1120,7 @@ public function evaluateFlags( try { $value = $this->computeFlagLocally( $flag, + $definitionSnapshot, $distinctId, $groups, $personProperties, @@ -1320,8 +1329,26 @@ public function logWarning(string $message): void error_log("[PostHog][Client] " . $message); } + /** + * Capture one definition context for an entire local evaluation, including dependencies. + * PHP arrays are copy-on-write, so a reentrant reload cannot change this snapshot. + * + * @return array + */ + private function flagDefinitionSnapshot(): array + { + return [ + 'flags' => $this->featureFlags, + 'flags_by_key' => $this->featureFlagsByKey, + 'group_type_mapping' => $this->groupTypeMapping, + 'cohorts' => $this->cohorts, + 'property_matching_version' => $this->propertyMatchingVersion, + ]; + } + private function computeFlagLocally( array $featureFlag, + array $definitionSnapshot, string $distinctId, array $groups = array(), array $personProperties = array(), @@ -1342,7 +1369,7 @@ private function computeFlagLocally( $aggregationGroupTypeIndex = $flagFilters["aggregation_group_type_index"] ?? null; if (!is_null($aggregationGroupTypeIndex)) { - $groupName = $this->groupTypeMapping[strval($aggregationGroupTypeIndex)] ?? null; + $groupName = $definitionSnapshot['group_type_mapping'][strval($aggregationGroupTypeIndex)] ?? null; if (is_null($groupName)) { throw new InconclusiveMatchException("Flag has unknown group type index"); @@ -1357,12 +1384,13 @@ private function computeFlagLocally( $featureFlag, $groups[$groupName], $focusedGroupProperties, - $this->cohorts, - $this->featureFlagsByKey, + $definitionSnapshot['cohorts'], + $definitionSnapshot['flags_by_key'], $evaluationCache, $groups, $groupProperties, - $this->groupTypeMapping + $definitionSnapshot['group_type_mapping'], + $definitionSnapshot['property_matching_version'] ); } else { $localPersonProperties = $personProperties; @@ -1374,12 +1402,13 @@ private function computeFlagLocally( $featureFlag, $distinctId, $localPersonProperties, - $this->cohorts, - $this->featureFlagsByKey, + $definitionSnapshot['cohorts'], + $definitionSnapshot['flags_by_key'], $evaluationCache, $groups, $groupProperties, - $this->groupTypeMapping + $definitionSnapshot['group_type_mapping'], + $definitionSnapshot['property_matching_version'] ); } } @@ -1561,6 +1590,7 @@ private function normalizeFlagDefinitionData(array $data): array : [], 'cohorts' => isset($data['cohorts']) && is_array($data['cohorts']) ? $data['cohorts'] : [], 'minimal_flag_called_events' => ($data['minimal_flag_called_events'] ?? null) === true, + 'property_matching_version' => $data['property_matching_version'] ?? 1, ]; } @@ -1598,6 +1628,7 @@ private function normalizeFlagDefinitionCacheData(array $data): ?array 'group_type_mapping' => $groupTypeMapping, 'cohorts' => $data['cohorts'], 'minimal_flag_called_events' => ($data['minimal_flag_called_events'] ?? null) === true, + 'property_matching_version' => $data['property_matching_version'] ?? 1, ]; } @@ -1612,6 +1643,7 @@ private function applyFlagDefinitions(array $data): void $this->featureFlags = $data['flags']; $this->groupTypeMapping = $data['group_type_mapping']; $this->cohorts = $data['cohorts']; + $this->propertyMatchingVersion = $data['property_matching_version']; $this->minimalFlagCalledEvents = $data['minimal_flag_called_events']; // Build flags by key dictionary for dependency resolution diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 90c9f14..6d13da3 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -18,10 +18,11 @@ class FeatureFlag * * @param array $property Feature flag property filter. * @param array $propertyValues Available property values keyed by property name. + * @param mixed $propertyMatchingVersion Definition snapshot selector; only 2 enables explicit matching. * @return bool Whether the property matches. * @throws InconclusiveMatchException When the property cannot be evaluated locally. */ - public static function matchProperty($property, $propertyValues) + public static function matchProperty($property, $propertyValues, $propertyMatchingVersion = 1) { $key = $property["key"]; $operator = $property["operator"] ?? "exact"; @@ -38,11 +39,11 @@ public static function matchProperty($property, $propertyValues) $overrideValue = $propertyValues[$key]; if ($operator == "exact") { - return FeatureFlag::computeExactMatch($value, $overrideValue); + return FeatureFlag::computeExactMatch($value, $overrideValue, $propertyMatchingVersion); } if ($operator == "is_not") { - return !FeatureFlag::computeExactMatch($value, $overrideValue); + return !FeatureFlag::computeExactMatch($value, $overrideValue, $propertyMatchingVersion); } if ($operator == "is_set") { @@ -186,12 +187,20 @@ public static function matchProperty($property, $propertyValues) * @param array>|null $flagsByKey Local feature flag definitions keyed by key. * @param array|null $evaluationCache Cache used for flag dependency evaluation. * @param string|null $distinctId Distinct ID used for nested flag dependency evaluation. + * @param mixed $propertyMatchingVersion Definition snapshot selector; defaults to legacy matching. * @return bool Whether the cohort matches. * @throws RequiresServerEvaluationException When the cohort requires server-side data. * @throws InconclusiveMatchException When the cohort cannot be evaluated locally. */ - public static function matchCohort($property, $propertyValues, $cohortProperties, $flagsByKey = null, $evaluationCache = null, $distinctId = null) - { + public static function matchCohort( + $property, + $propertyValues, + $cohortProperties, + $flagsByKey = null, + $evaluationCache = null, + $distinctId = null, + $propertyMatchingVersion = 1 + ) { $cohortId = strval($property["value"]); if (!array_key_exists($cohortId, $cohortProperties)) { throw new RequiresServerEvaluationException( @@ -201,7 +210,15 @@ public static function matchCohort($property, $propertyValues, $cohortProperties } $propertyGroup = $cohortProperties[$cohortId]; - return FeatureFlag::matchPropertyGroup($propertyGroup, $propertyValues, $cohortProperties, $flagsByKey, $evaluationCache, $distinctId); + return FeatureFlag::matchPropertyGroup( + $propertyGroup, + $propertyValues, + $cohortProperties, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyMatchingVersion + ); } /** @@ -213,12 +230,20 @@ public static function matchCohort($property, $propertyValues, $cohortProperties * @param array>|null $flagsByKey Local feature flag definitions keyed by key. * @param array|null $evaluationCache Cache used for flag dependency evaluation. * @param string|null $distinctId Distinct ID used for nested flag dependency evaluation. + * @param mixed $propertyMatchingVersion Definition snapshot selector; defaults to legacy matching. * @return bool Whether the property group matches. * @throws RequiresServerEvaluationException When server-side data is required. * @throws InconclusiveMatchException When the group cannot be evaluated locally. */ - public static function matchPropertyGroup($propertyGroup, $propertyValues, $cohortProperties, $flagsByKey = null, $evaluationCache = null, $distinctId = null) - { + public static function matchPropertyGroup( + $propertyGroup, + $propertyValues, + $cohortProperties, + $flagsByKey = null, + $evaluationCache = null, + $distinctId = null, + $propertyMatchingVersion = 1 + ) { if (!$propertyGroup) { return true; } @@ -237,7 +262,15 @@ public static function matchPropertyGroup($propertyGroup, $propertyValues, $coho // a nested property group foreach ($properties as $prop) { try { - $matches = FeatureFlag::matchPropertyGroup($prop, $propertyValues, $cohortProperties, $flagsByKey, $evaluationCache, $distinctId); + $matches = FeatureFlag::matchPropertyGroup( + $prop, + $propertyValues, + $cohortProperties, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyMatchingVersion + ); if ($propertyGroupType === 'AND') { if (!$matches) { return false; @@ -267,11 +300,27 @@ public static function matchPropertyGroup($propertyGroup, $propertyValues, $coho $matches = false; $propType = $prop["type"] ?? null; if ($propType === 'cohort') { - $matches = FeatureFlag::matchCohort($prop, $propertyValues, $cohortProperties, $flagsByKey, $evaluationCache, $distinctId); + $matches = FeatureFlag::matchCohort( + $prop, + $propertyValues, + $cohortProperties, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyMatchingVersion + ); } elseif ($propType === 'flag') { - $matches = FeatureFlag::evaluateFlagDependency($prop, $flagsByKey, $evaluationCache, $distinctId, $propertyValues, $cohortProperties); + $matches = FeatureFlag::evaluateFlagDependency( + $prop, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyValues, + $cohortProperties, + $propertyMatchingVersion + ); } else { - $matches = FeatureFlag::matchProperty($prop, $propertyValues); + $matches = FeatureFlag::matchProperty($prop, $propertyValues, $propertyMatchingVersion); } $negation = $prop["negation"] ?? false; @@ -556,12 +605,16 @@ private static function convertToDateTime($value) } } - private static function computeExactMatch($value, $overrideValue) + private static function computeExactMatch($value, $overrideValue, $propertyMatchingVersion) { - if (FeatureFlag::isTruthyOrFalsyPropertyValue($value)) { + if ($propertyMatchingVersion !== 2 && FeatureFlag::isTruthyOrFalsyPropertyValue($value)) { return FeatureFlag::isTruthyPropertyValue($value) === FeatureFlag::isTruthyPropertyValue($overrideValue); } + if ($value === []) { + return FeatureFlag::isTruthyPropertyValue($overrideValue); + } + $overrideString = FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($overrideValue)); if (is_array($value) && array_is_list($value)) { foreach ($value as $candidate) { @@ -840,6 +893,7 @@ private static function variantLookupTable($featureFlag) * @param array $groups Group identifiers for group-based flags. * @param array> $groupProperties Group properties for evaluation. * @param array $groupTypeMapping Mapping from group type index to group type name. + * @param mixed $propertyMatchingVersion Definition snapshot selector; defaults to legacy matching. * @return bool|string False for disabled, true for enabled boolean flags, or variant key. * @throws RequiresServerEvaluationException When server-side data is required. * @throws InconclusiveMatchException When the flag cannot be evaluated locally. @@ -853,7 +907,8 @@ public static function matchFeatureFlagProperties( $evaluationCache = null, $groups = [], $groupProperties = [], - $groupTypeMapping = [] + $groupTypeMapping = [], + $propertyMatchingVersion = 1 ) { $flagFilters = $flag["filters"] ?? []; $flagConditions = $flagFilters["groups"] ?? []; @@ -891,7 +946,16 @@ public static function matchFeatureFlagProperties( } } - $matchResult = FeatureFlag::isConditionMatch($flag, $effectiveBucketing, $condition, $effectiveProperties, $cohorts, $flagsByKey, $evaluationCache); + $matchResult = FeatureFlag::isConditionMatch( + $flag, + $effectiveBucketing, + $condition, + $effectiveProperties, + $cohorts, + $flagsByKey, + $evaluationCache, + $propertyMatchingVersion + ); if ($matchResult === ConditionMatch::Match) { $variantOverride = $condition["variant"] ?? null; @@ -940,8 +1004,16 @@ public static function matchFeatureFlagProperties( return false; } - private static function isConditionMatch($featureFlag, $distinctId, $condition, $properties, $cohorts, $flagsByKey = null, $evaluationCache = null): ConditionMatch - { + private static function isConditionMatch( + $featureFlag, + $distinctId, + $condition, + $properties, + $cohorts, + $flagsByKey = null, + $evaluationCache = null, + $propertyMatchingVersion = 1 + ): ConditionMatch { $rolloutPercentage = array_key_exists("rollout_percentage", $condition) ? $condition["rollout_percentage"] : null; if (count($condition['properties'] ?? []) > 0) { @@ -949,11 +1021,27 @@ private static function isConditionMatch($featureFlag, $distinctId, $condition, $matches = false; $propertyType = $property['type'] ?? null; if ($propertyType == 'cohort') { - $matches = FeatureFlag::matchCohort($property, $properties, $cohorts, $flagsByKey, $evaluationCache, $distinctId); + $matches = FeatureFlag::matchCohort( + $property, + $properties, + $cohorts, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyMatchingVersion + ); } elseif ($propertyType == 'flag') { - $matches = FeatureFlag::evaluateFlagDependency($property, $flagsByKey, $evaluationCache, $distinctId, $properties, $cohorts); + $matches = FeatureFlag::evaluateFlagDependency( + $property, + $flagsByKey, + $evaluationCache, + $distinctId, + $properties, + $cohorts, + $propertyMatchingVersion + ); } else { - $matches = FeatureFlag::matchProperty($property, $properties); + $matches = FeatureFlag::matchProperty($property, $properties, $propertyMatchingVersion); } if (!$matches) { @@ -1014,11 +1102,19 @@ private static function prepareValueForRegex($value) * @param string $distinctId Distinct ID used for bucketing. * @param array $properties Person or group properties for evaluation. * @param array $cohortProperties Local cohort definitions keyed by cohort ID. + * @param mixed $propertyMatchingVersion Definition snapshot selector; defaults to legacy matching. * @return bool Whether the dependency matches. * @throws InconclusiveMatchException When the dependency cannot be evaluated locally. */ - public static function evaluateFlagDependency($property, $flagsByKey, $evaluationCache, $distinctId, $properties, $cohortProperties) - { + public static function evaluateFlagDependency( + $property, + $flagsByKey, + $evaluationCache, + $distinctId, + $properties, + $cohortProperties, + $propertyMatchingVersion = 1 + ) { if ($flagsByKey === null || $evaluationCache === null) { throw new InconclusiveMatchException(sprintf( "Cannot evaluate flag dependency on '%s' without flags_by_key and evaluation_cache", @@ -1077,7 +1173,8 @@ public static function evaluateFlagDependency($property, $flagsByKey, $evaluatio $properties, $cohortProperties, $flagsByKey, - $evaluationCache + $evaluationCache, + propertyMatchingVersion: $propertyMatchingVersion ); $evaluationCache[$depFlagKey] = $depResult; } catch (InconclusiveMatchException $e) { diff --git a/lib/FlagDefinitionCacheProvider.php b/lib/FlagDefinitionCacheProvider.php index 7a7b3ce..f209a39 100644 --- a/lib/FlagDefinitionCacheProvider.php +++ b/lib/FlagDefinitionCacheProvider.php @@ -15,8 +15,11 @@ interface FlagDefinitionCacheProvider * Retrieve cached local-evaluation flag definitions. * * Return null when the external cache is empty or unavailable. Returned data should include the - * complete definition set: flags, group_type_mapping, and cohorts. groupTypeMapping is also - * accepted when reading cached data for integrations that prefer camelCase. + * complete definition set: flags, group_type_mapping, cohorts, and property_matching_version. + * Preserve property_matching_version together with the definitions: only 2 selects explicit + * property matching; missing/1 and other versions use legacy matching. Older cache entries + * without this field remain supported and reset matching to legacy when loaded. + * groupTypeMapping is also accepted for integrations that prefer camelCase. * * @return array|null */ @@ -34,6 +37,7 @@ public function shouldFetchFlagDefinitions(): bool; /** * Receive definitions fetched successfully from PostHog so they can be stored externally. + * Store the complete array, including property_matching_version, as one snapshot. * * @param array $data * @return void diff --git a/test/FlagDefinitionCacheProviderTest.php b/test/FlagDefinitionCacheProviderTest.php index 2845092..1ef5742 100644 --- a/test/FlagDefinitionCacheProviderTest.php +++ b/test/FlagDefinitionCacheProviderTest.php @@ -331,6 +331,225 @@ public function testInvalidProviderOptionThrows(): void ); } + public function testMatchingVersionSurvivesApiAndProviderRoundTrip(): void + { + $provider = new MockFlagDefinitionCacheProvider(); + $httpClient = new MockedHttpClient( + host: 'app.posthog.com', + flagEndpointResponse: $this->versionedDefinitions(2) + ); + $client = $this->createClient($provider, $httpClient); + $this->assertVersionedResults($client, false); + $this->assertSame(2, $provider->storedData['property_matching_version']); + + $provider->shouldFetch = false; + $provider->cachedData = $provider->storedData; + $cacheHttp = new MockedHttpClient(host: 'app.posthog.com'); + $cachedClient = $this->createClient($provider, $cacheHttp); + $this->assertVersionedResults($cachedClient, false); + $this->assertSame([], $cacheHttp->calls ?? []); + $this->assertOnlyDefinitionsRequests($httpClient); + } + + public function testVersionOnlyApiReloadAnd304OrFailurePreserveSnapshot(): void + { + $provider = new MockFlagDefinitionCacheProvider(); + $httpClient = new MockedHttpClient(host: 'app.posthog.com'); + $httpClient->setFlagEndpointResponseQueue([ + ['response' => $this->versionedDefinitions(1), 'etag' => 'legacy'], + ['response' => $this->versionedDefinitions(2), 'etag' => 'explicit'], + ['responseCode' => 304], + ['responseCode' => 500], + ['response' => $this->versionedDefinitions(1)], + ['response' => $this->versionedDefinitions(2)], + ['response' => $this->versionedDefinitions(null)], + ['response' => $this->versionedDefinitions(3)], + ]); + $client = $this->createClient($provider, $httpClient); + foreach ([true, false, false, false, true, false, true, true] as $index => $expected) { + if ($index > 0) { + $client->loadFlags(); + } + $this->assertVersionedResults($client, $expected); + if ($index === 2 || $index === 3) { + $this->assertSame('explicit', $client->getFlagsEtag()); + $this->assertSame(2, $provider->storedData['property_matching_version']); + } + } + $this->assertOnlyDefinitionsRequests($httpClient); + } + + public function testVersionOnlyCacheReloadAndReadFailure(): void + { + $provider = new MockFlagDefinitionCacheProvider(); + $provider->shouldFetch = false; + $provider->cachedData = $this->versionedDefinitions(1); + $httpClient = new MockedHttpClient(host: 'app.posthog.com'); + $client = $this->createClient($provider, $httpClient); + foreach ([1, 2, 1, 2, null, 3] as $version) { + $provider->cachedData = $this->versionedDefinitions($version); + // Also cover the provider's supported camelCase projection. + $provider->cachedData['groupTypeMapping'] = $provider->cachedData['group_type_mapping']; + unset($provider->cachedData['group_type_mapping']); + $client->loadFlags(); + $this->assertVersionedResults($client, $version !== 2); + if ($version === 2) { + $provider->getError = new \RuntimeException('read failed'); + $client->loadFlags(); + $this->assertVersionedResults($client, false); + $provider->getError = null; + $provider->cachedData = ['flags' => 'malformed']; + $client->loadFlags(); + $this->assertVersionedResults($client, false); + $provider->cachedData = null; + $client->loadFlags(); + $this->assertVersionedResults($client, false); + } + } + $this->assertSame([], $httpClient->calls ?? []); + } + + public function testReloadDuringEvaluationDoesNotChangeItsDefinitionSnapshot(): void + { + foreach (['all', 'snapshot', 'single'] as $api) { + $provider = new MockFlagDefinitionCacheProvider(); + $provider->shouldFetch = false; + $definitions = $this->versionedDefinitions(2); + $definitions['flags'][0]['filters']['groups'][0]['properties'] = [ + ['key' => 'reload', 'value' => '"trigger"', 'operator' => 'exact'], + ['key' => 'value', 'value' => false, 'operator' => 'exact'], + ]; + $provider->cachedData = $definitions; + $httpClient = new MockedHttpClient(host: 'app.posthog.com'); + $client = $this->createClient($provider, $httpClient); + $provider->cachedData = $this->versionedDefinitions(1); + $reloadValue = new class ($client) implements \JsonSerializable { + public function __construct(private Client $client) + { + } + + public function jsonSerialize(): mixed + { + $this->client->loadFlags(); + return 'trigger'; + } + }; + $personProperties = ['value' => 'banana', 'reload' => $reloadValue]; + $groups = ['company' => 'acme']; + $groupProperties = ['company' => ['value' => 'banana']]; + if ($api === 'all') { + $results = $client->getAllFlags('user', $groups, $personProperties, $groupProperties); + $this->assertSame(array_fill_keys(array_column($definitions['flags'], 'key'), false), $results); + } elseif ($api === 'snapshot') { + $snapshot = $client->evaluateFlags('user', $groups, $personProperties, $groupProperties); + foreach (array_column($definitions['flags'], 'key') as $key) { + $this->assertFalse($snapshot->getFlag($key)); + } + } else { + set_error_handler(static fn ($errno) => $errno === E_USER_DEPRECATED, E_USER_DEPRECATED); + try { + $this->assertFalse($client->getFeatureFlag( + 'person', 'user', $groups, $personProperties, $groupProperties, false, false + )); + } finally { + restore_error_handler(); + } + } + // The reentrant reload applies to the next call, not the evaluation in progress. + $this->assertVersionedResults($client, true); + $this->assertSame([], $httpClient->calls ?? []); + } + } + + public function testVersionedMissingPropertyStillFallsBackRemotely(): void + { + foreach ([1, 2] as $version) { + $provider = new MockFlagDefinitionCacheProvider(); + $provider->shouldFetch = false; + $provider->cachedData = $this->versionedDefinitions($version); + $httpClient = new MockedHttpClient( + host: 'app.posthog.com', + flagsEndpointResponse: ['flags' => ['person' => ['enabled' => true, 'variant' => null]]] + ); + $client = $this->createClient($provider, $httpClient); + $local = $client->evaluateFlags('user', onlyEvaluateLocally: true, flagKeys: ['person']); + $this->assertSame([], $local->getKeys()); + $this->assertSame([], $httpClient->calls ?? []); + $remote = $client->evaluateFlags('user', flagKeys: ['person']); + $this->assertTrue($remote->getFlag('person')); + $this->assertCount(1, $httpClient->calls); + $this->assertStringStartsWith('/flags/?', $httpClient->calls[0]['path']); + } + } + + private function assertVersionedResults(Client $client, bool $expected): void + { + $groups = ['company' => 'acme']; + $properties = ['value' => 'banana']; + $groupProperties = ['company' => $properties]; + $expectedFlags = array_fill_keys(['person', 'group', 'mixed', 'cohort', 'dependency', 'cohort-dependency'], $expected); + $this->assertSame($expectedFlags, $client->getAllFlags('user', $groups, $properties, $groupProperties)); + $snapshot = $client->evaluateFlags('user', $groups, $properties, $groupProperties); + foreach ($expectedFlags as $key => $value) { + $this->assertSame($value, $snapshot->getFlag($key)); + } + // Exercise the legacy single-flag entry point without its intentional deprecation warning. + set_error_handler(static fn ($errno) => $errno === E_USER_DEPRECATED, E_USER_DEPRECATED); + try { + $this->assertSame($expected, $client->getFeatureFlag( + 'person', 'user', $groups, $properties, $groupProperties, false, false + )); + } finally { + restore_error_handler(); + } + } + + private function assertOnlyDefinitionsRequests(MockedHttpClient $httpClient): void + { + foreach ($httpClient->calls ?? [] as $call) { + $this->assertStringStartsWith('/flags/definitions?', $call['path']); + } + } + + private function versionedDefinitions(?int $version): array + { + $leaf = ['key' => 'value', 'type' => 'person', 'value' => false, 'operator' => 'exact']; + $cohort = ['key' => 'id', 'type' => 'cohort', 'value' => 1]; + $dependency = [ + 'key' => 'person', 'type' => 'flag', 'value' => true, + 'operator' => 'flag_evaluates_to', 'dependency_chain' => ['person'], + ]; + $makeFlag = static fn ($key, $property) => [ + 'key' => $key, 'active' => true, 'version' => 2, + 'filters' => ['groups' => [['properties' => [$property], 'rollout_percentage' => 100]]], + ]; + $flags = [ + $makeFlag('person', $leaf), + $makeFlag('group', $leaf), + $makeFlag('mixed', $leaf), + $makeFlag('cohort', $cohort), + $makeFlag('dependency', $dependency), + $makeFlag('cohort-dependency', ['key' => 'id', 'type' => 'cohort', 'value' => 3]), + ]; + $flags[1]['filters']['aggregation_group_type_index'] = 0; + $flags[2]['filters']['groups'][0]['aggregation_group_type_index'] = 0; + $data = [ + 'flags' => $flags, + 'group_type_mapping' => ['0' => 'company'], + 'cohorts' => [ + '1' => ['type' => 'AND', 'values' => [ + ['type' => 'OR', 'values' => [['key' => 'id', 'type' => 'cohort', 'value' => 2]]], + ]], + '2' => ['type' => 'AND', 'values' => [$leaf]], + '3' => ['type' => 'AND', 'values' => [$dependency]], + ], + ]; + if ($version !== null) { + $data['property_matching_version'] = $version; + } + return $data; + } + private function createClient(MockFlagDefinitionCacheProvider $provider, MockedHttpClient $httpClient): Client { return new Client( diff --git a/test/VersionedPropertyMatchingTest.php b/test/VersionedPropertyMatchingTest.php new file mode 100644 index 0000000..17da7d3 --- /dev/null +++ b/test/VersionedPropertyMatchingTest.php @@ -0,0 +1,83 @@ + [false, 'banana', true, false], + 'false zero' => [false, 0, true, false], + 'boolean list true' => [['true', 'false'], 'true', false, true], + 'boolean list pro' => [['true', 'false'], 'pro', true, false], + 'empty true' => [[], true, true, true], + 'empty empty' => [[], [], true, true], + 'true list' => [true, [true], true, false], + 'false uppercase' => [false, 'FALSE', true, true], + 'false null' => [false, null, true, false], + 'false empty string' => [false, '', true, false], + 'empty nested truthy' => [[], [true, 'TRUE', [[]]], true, true], + 'empty nested falsy' => [[], [true, [0]], false, false], + 'empty false' => [[], false, false, false], + 'empty zero' => [[], 0, false, false], + 'empty banana' => [[], 'banana', false, false], + 'empty null' => [[], null, false, false], + 'mixed boolean' => [[false, 'PRO'], 'FALSE', true, true], + 'mixed string' => [[true, 'PRO'], 'pro', true, true], + 'mixed numeric' => [[1, 'PRO'], '1', true, true], + 'mixed null' => [[null, 'PRO'], 'null', true, true], + 'nested normalized array' => [[[true, true], 'PRO'], [true, true], true, true], + 'whole property array' => [[true], [true], true, false], + 'recursive boolean filter' => [[[true, false]], 'banana', true, false], + 'nested empty filter' => [[[]], true, true, false], + 'nested empty member' => [[[]], [], true, true], + 'boolean list boolean true' => [['TrUe', 'FALSE'], true, false, true], + 'boolean list boolean false' => [['TrUe', 'FALSE'], false, true, true], + 'null normalized' => ['null', null, true, true], + 'unicode expansion' => ['İ', "i\u{0307}", true, true], + 'unicode sigma' => ['ΟΣ', 'ος', true, true], + 'normal string' => ['PRO', 'pro', true, true], + ]; + foreach ($rows as $name => [$filter, $property, $legacy, $explicit]) { + foreach ([null, 1, 2, 0, 3, '2'] as $version) { + yield $name . ' version ' . json_encode($version) => [ + $filter, $property, $version, $version === 2 ? $explicit : $legacy, + ]; + } + } + } + + #[DataProvider('matchingCases')] + public function testExactAndIsNot($filter, $value, $version, bool $expected): void + { + foreach (['exact', 'is_not'] as $operator) { + $property = ['key' => 'value', 'value' => $filter, 'operator' => $operator]; + $actual = $version === null + ? FeatureFlag::matchProperty($property, ['value' => $value]) + : FeatureFlag::matchProperty($property, ['value' => $value], $version); + self::assertSame($operator === 'exact' ? $expected : !$expected, $actual); + } + } + + public static function missingCases(): iterable + { + foreach ([1, 2] as $version) { + foreach (['exact', 'is_not'] as $operator) { + yield [$version, $operator]; + } + } + } + + #[DataProvider('missingCases')] + public function testMissingPropertyRemainsInconclusive(int $version, string $operator): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty(['key' => 'missing', 'value' => false, 'operator' => $operator], [], $version); + } +}