diff --git a/lib/Client.php b/lib/Client.php index 0f332a5..6296223 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -620,7 +620,10 @@ private function computeFlagLocally( $focusedGroupProperties, $this->cohorts, $this->featureFlagsByKey, - $evaluationCache + $evaluationCache, + $groups, + $groupProperties, + $this->groupTypeMapping ); } else { return FeatureFlag::matchFeatureFlagProperties( @@ -629,7 +632,10 @@ private function computeFlagLocally( $personProperties, $this->cohorts, $this->featureFlagsByKey, - $evaluationCache + $evaluationCache, + $groups, + $groupProperties, + $this->groupTypeMapping ); } } diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 4fd451f..39d2311 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -586,16 +586,55 @@ private static function variantLookupTable($featureFlag) return $lookupTable; } - public static function matchFeatureFlagProperties($flag, $distinctId, $properties, $cohorts = [], $flagsByKey = null, $evaluationCache = null) - { - $flagConditions = ($flag["filters"] ?? [])["groups"] ?? []; + public static function matchFeatureFlagProperties( + $flag, + $distinctId, + $properties, + $cohorts = [], + $flagsByKey = null, + $evaluationCache = null, + $groups = [], + $groupProperties = [], + $groupTypeMapping = [] + ) { + $flagFilters = $flag["filters"] ?? []; + $flagConditions = $flagFilters["groups"] ?? []; + $flagAggregation = $flagFilters["aggregation_group_type_index"] ?? null; $isInconclusive = false; foreach ($flagConditions as $condition) { try { - if (FeatureFlag::isConditionMatch($flag, $distinctId, $condition, $properties, $cohorts, $flagsByKey, $evaluationCache)) { + // Per-condition aggregation overrides only when the condition explicitly + // sets its own aggregation_group_type_index (mixed targeting). When absent, + // use the properties/bucketing already resolved by the caller. + $hasConditionAggregation = array_key_exists("aggregation_group_type_index", $condition); + $conditionAggregation = $hasConditionAggregation + ? $condition["aggregation_group_type_index"] + : $flagAggregation; + + $effectiveProperties = $properties; + $effectiveBucketing = $distinctId; + + // Mixed-override path: condition-level aggregation differs from flag-level. + // This assumes flag-level aggregation is null for mixed flags. + if ($conditionAggregation !== $flagAggregation) { + if (!is_null($conditionAggregation)) { + $groupName = $groupTypeMapping[strval($conditionAggregation)] ?? null; + if (is_null($groupName) || !array_key_exists($groupName, $groups)) { + continue; + } + if (!array_key_exists($groupName, $groupProperties)) { + $isInconclusive = true; + continue; + } + $effectiveProperties = $groupProperties[$groupName]; + $effectiveBucketing = $groups[$groupName]; + } + } + + if (FeatureFlag::isConditionMatch($flag, $effectiveBucketing, $condition, $effectiveProperties, $cohorts, $flagsByKey, $evaluationCache)) { $variantOverride = $condition["variant"] ?? null; - $flagVariants = (($flag["filters"] ?? [])["multivariate"] ?? [])["variants"] ?? []; + $flagVariants = ($flagFilters["multivariate"] ?? [])["variants"] ?? []; $variantKeys = array_map(function ($variant) { return $variant["key"]; }, $flagVariants); @@ -603,7 +642,7 @@ public static function matchFeatureFlagProperties($flag, $distinctId, $propertie if ($variantOverride && in_array($variantOverride, $variantKeys)) { return $variantOverride; } else { - return FeatureFlag::getMatchingVariant($flag, $distinctId) ?? true; + return FeatureFlag::getMatchingVariant($flag, $effectiveBucketing) ?? true; } } } catch (RequiresServerEvaluationException $e) { diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 13cc28a..bdd9f12 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -1153,6 +1153,83 @@ public function testFlagGroupProperties() $this->assertEquals(PostHog::getFeatureFlag('group-flag', 'some-distinct-id', ["company" => "amazon"], [], ["company" => []]), 'decide-fallback-value'); } + /** + * @dataProvider mixedTargetingProvider + */ + public function testMixedTargetingLocalEvaluation(string $flagKey, array $opts, $expected, array $localFlags) + { + $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: $localFlags); + $this->client = new Client(self::FAKE_API_KEY, ["debug" => true], $this->http_client, "test"); + PostHog::init(null, null, $this->client); + + $result = PostHog::getFeatureFlag( + $flagKey, + 'test-distinct-id', + $opts['groups'] ?? [], + $opts['person_properties'] ?? [], + $opts['group_properties'] ?? [] + ); + $this->assertSame($expected, $result); + } + + public static function mixedTargetingProvider(): array + { + return [ + 'person condition matches when no groups passed' => [ + 'mixed-flag', + ['person_properties' => ['email' => 'test@example.com']], + true, + MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST, + ], + 'group condition matches when group props match' => [ + 'mixed-flag', + [ + 'groups' => ['company' => 'acme'], + 'group_properties' => ['company' => ['plan' => 'enterprise']], + 'person_properties' => ['email' => 'nope@example.com'], + ], + true, + MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST, + ], + 'no match when both person and group fail' => [ + 'mixed-flag', + [ + 'groups' => ['company' => 'acme'], + 'group_properties' => ['company' => ['plan' => 'free']], + 'person_properties' => ['email' => 'nope@example.com'], + ], + false, + MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST, + ], + 'only group condition, no groups passed: returns false without server fallback' => [ + 'only-group-flag', + [], + false, + MockedResponses::LOCAL_EVALUATION_ONLY_GROUP_CONDITION_REQUEST, + ], + ]; + } + + public function testMixedTargetingRolloutBucketing() + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST + ); + $this->client = new Client(self::FAKE_API_KEY, ["debug" => true], $this->http_client, "test"); + PostHog::init(null, null, $this->client); + + // With rollout 100% and a group passed, the group condition resolves locally — + // the matcher must hash on the group key, not the distinct_id. + $this->assertTrue(PostHog::getFeatureFlag( + 'rollout-flag', + 'any-distinct-id', + ["company" => "acme"], + [], + ["company" => []] + )); + } + public function testFlagComplexDefinition() { $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_COMPLEX_FLAG_REQUEST); diff --git a/test/assests/MockedResponses.php b/test/assests/MockedResponses.php index 06c642c..9cc8a6a 100644 --- a/test/assests/MockedResponses.php +++ b/test/assests/MockedResponses.php @@ -768,6 +768,118 @@ class MockedResponses ] ]; + public const LOCAL_EVALUATION_MIXED_TARGETING_REQUEST = [ + 'count' => 1, + 'next' => null, + 'previous' => null, + 'flags' => [ + [ + "id" => 1, + "name" => "Mixed Flag", + "key" => "mixed-flag", + "filters" => [ + "aggregation_group_type_index" => null, + "groups" => [ + [ + "aggregation_group_type_index" => 0, + "properties" => [ + [ + "key" => "plan", + "value" => ["enterprise"], + "operator" => "exact", + "type" => "group", + "group_type_index" => 0 + ] + ], + "rollout_percentage" => 100 + ], + [ + "aggregation_group_type_index" => null, + "properties" => [ + [ + "key" => "email", + "value" => ["test@example.com"], + "operator" => "exact", + "type" => "person" + ] + ], + "rollout_percentage" => 100 + ] + ] + ], + "deleted" => false, + "active" => true, + "is_simple_flag" => false, + "rollout_percentage" => null + ] + ], + 'group_type_mapping' => ["0" => "company"] + ]; + + public const LOCAL_EVALUATION_ONLY_GROUP_CONDITION_REQUEST = [ + 'count' => 1, + 'next' => null, + 'previous' => null, + 'flags' => [ + [ + "id" => 2, + "name" => "Only Group Flag", + "key" => "only-group-flag", + "filters" => [ + "aggregation_group_type_index" => null, + "groups" => [ + [ + "aggregation_group_type_index" => 0, + "properties" => [ + [ + "key" => "plan", + "value" => ["enterprise"], + "operator" => "exact", + "type" => "group", + "group_type_index" => 0 + ] + ], + "rollout_percentage" => 100 + ] + ] + ], + "deleted" => false, + "active" => true, + "is_simple_flag" => false, + "rollout_percentage" => null + ] + ], + 'group_type_mapping' => ["0" => "company"] + ]; + + public const LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST = [ + 'count' => 1, + 'next' => null, + 'previous' => null, + 'flags' => [ + [ + "id" => 3, + "name" => "Rollout Flag", + "key" => "rollout-flag", + "filters" => [ + "aggregation_group_type_index" => null, + "groups" => [ + [ + "aggregation_group_type_index" => 0, + "properties" => [], + "rollout_percentage" => 100 + ] + ] + ], + "deleted" => false, + "active" => true, + "is_simple_flag" => false, + "rollout_percentage" => null + ] + ], + 'group_type_mapping' => ["0" => "company"] + ]; + public const LOCAL_EVALUATION_COMPLEX_FLAG_REQUEST = [ 'count' => 1, 'next' => null,