From bfde80c822edd443d158606df8ab93e44dfb2cfe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 07:25:45 +0200 Subject: [PATCH 1/6] fix(flags): align local string matching with the flags service --- .changeset/local-flag-case-folding.md | 5 ++ composer.json | 3 +- composer.lock | 87 ++++++++++++++++++++++++- lib/FeatureFlag.php | 23 +++++-- test/FeatureFlagLocalEvaluationTest.php | 65 ++++++++++++++++++ 5 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 .changeset/local-flag-case-folding.md diff --git a/.changeset/local-flag-case-folding.md b/.changeset/local-flag-case-folding.md new file mode 100644 index 0000000..bfebaae --- /dev/null +++ b/.changeset/local-flag-case-folding.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Match local feature flag ASCII and Unicode case handling and numeric stringification with the flags service. diff --git a/composer.json b/composer.json index 5e7d768..c9a1168 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "require": { "ext-json": "*", "php": ">=8.2", - "symfony/clock": "^6.2|^7.0|^8.0" + "symfony/clock": "^6.2|^7.0|^8.0", + "symfony/polyfill-mbstring": "^1.31" }, "require-dev": { "phpunit/phpunit": "^11.0", diff --git a/composer.lock b/composer.lock index 84226ca..03bb94b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d5821b77f51196ecaf707d618b7bba7c", + "content-hash": "fcbecc0a26b21917b79a5a17199e10e5", "packages": [ { "name": "psr/clock", @@ -132,6 +132,91 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.33.0", diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 3bccd05..b00ad60 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -54,7 +54,7 @@ public static function matchProperty($property, $propertyValues) } if ($operator == "not_icontains") { - return strpos(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))) == false; + return strpos(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))) === false; } if ($operator == "starts_with") { @@ -557,19 +557,32 @@ private static function convertToDateTime($value) private static function computeExactMatch($value, $overrideValue) { + $overrideString = FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($overrideValue)); if (is_array($value)) { - return in_array(strtolower(FeatureFlag::valueToString($overrideValue)), array_map('strtolower', array_map(fn($val) => FeatureFlag::valueToString($val), $value))); + foreach ($value as $candidate) { + if (FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($candidate)) === $overrideString) { + return true; + } + } + return false; } - return strtolower(FeatureFlag::valueToString($value)) == strtolower(FeatureFlag::valueToString($overrideValue)); + return FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($value)) === $overrideString; + } + + private static function unicodeLowercase($value) + { + return mb_strtolower($value, "UTF-8"); } private static function valueToString($value) { if (is_bool($value)) { return $value ? "true" : "false"; - } else { - return strval($value); } + if (is_float($value)) { + return json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); + } + return strval($value); } private static function compare($lhs, $rhs, $operator, $type = "string") diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 7809088..c0b253f 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -248,6 +248,53 @@ public function testMatchPropertyNotIn(): void ]); } + public function testMatchPropertyExactUsesUnicodeLowercase(): void + { + $exact = [ + "key" => "key", + "value" => "Ä", + "operator" => "exact", + ]; + + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "ä"])); + + $exact["value"] = ["FREE", "Ä"]; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "ä"])); + + $isNot = $exact; + $isNot["operator"] = "is_not"; + self::assertFalse(FeatureFlag::matchProperty($isNot, ["key" => "ä"])); + + foreach ([["ß", "ss"], ["Σ", "ς"]] as [$filter, $property]) { + $exact["value"] = $filter; + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => $property])); + + $isNot["value"] = $filter; + self::assertTrue(FeatureFlag::matchProperty($isNot, ["key" => $property])); + } + } + + public function testMatchPropertyStringificationPreservesIntegralFloats(): void + { + $exact = [ + "key" => "key", + "value" => "323.0", + "operator" => "exact", + ]; + + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => 323.0])); + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => 323])); + + $endsWith = [ + "key" => "key", + "value" => "3", + "operator" => "ends_with", + ]; + + self::assertFalse(FeatureFlag::matchProperty($endsWith, ["key" => 323.0])); + self::assertTrue(FeatureFlag::matchProperty($endsWith, ["key" => 323])); + } + /** * @dataProvider presentPresenceOperatorValuesProvider */ @@ -352,6 +399,24 @@ public function testMatchPropertyContains(): void self::assertFalse(FeatureFlag::matchProperty($prop, [ "key" => "three", ])); + + // Case folding is ASCII-only, mirroring the flags service. + $prop["value"] = "ä"; + self::assertFalse(FeatureFlag::matchProperty($prop, ["key" => "ÄBC"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["key" => "äbc"])); + } + + public function testMatchPropertyNotContainsNegatesMatchAtOffsetZero(): void + { + $prop = [ + "key" => "key", + "value" => "VALUE", + "operator" => "not_icontains", + ]; + + self::assertFalse(FeatureFlag::matchProperty($prop, ["key" => "value suffix"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["key" => "prefix value suffix"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["key" => "different"])); } public function testMatchPropertyStartsWith(): void From 31670334f28dbb84aff3c22144e526f2fb4fa603 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 07:28:19 +0200 Subject: [PATCH 2/6] fix(flags): preserve scientific float formatting --- lib/FeatureFlag.php | 6 +++++- test/FeatureFlagLocalEvaluationTest.php | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index b00ad60..3ea95e8 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -580,7 +580,11 @@ private static function valueToString($value) return $value ? "true" : "false"; } if (is_float($value)) { - return json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); + if (!is_finite($value)) { + return strval($value); + } + $encoded = json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); + return preg_replace('/\.0(e[+-]?\d+)$/', '$1', $encoded); } return strval($value); } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index c0b253f..e5fccec 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -293,6 +293,11 @@ public function testMatchPropertyStringificationPreservesIntegralFloats(): void self::assertFalse(FeatureFlag::matchProperty($endsWith, ["key" => 323.0])); self::assertTrue(FeatureFlag::matchProperty($endsWith, ["key" => 323])); + + foreach ([["1e+20", 1.0e20], ["-1e-7", -1.0e-7], ["INF", INF], ["NAN", NAN]] as [$filter, $property]) { + $exact["value"] = $filter; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => $property])); + } } /** From c37183ceb8b858ba571e90c3d4d9bba9b075ac9a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 07:31:28 +0200 Subject: [PATCH 3/6] fix(flags): use simple Unicode lowercase matching --- lib/FeatureFlag.php | 2 +- test/FeatureFlagLocalEvaluationTest.php | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 3ea95e8..7344c2d 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -571,7 +571,7 @@ private static function computeExactMatch($value, $overrideValue) private static function unicodeLowercase($value) { - return mb_strtolower($value, "UTF-8"); + return mb_convert_case($value, MB_CASE_LOWER_SIMPLE, "UTF-8"); } private static function valueToString($value) diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index e5fccec..ed10f1b 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -265,13 +265,16 @@ public function testMatchPropertyExactUsesUnicodeLowercase(): void $isNot["operator"] = "is_not"; self::assertFalse(FeatureFlag::matchProperty($isNot, ["key" => "ä"])); - foreach ([["ß", "ss"], ["Σ", "ς"]] as [$filter, $property]) { + foreach ([["ß", "ss"], ["Σ", "ς"], ["ΟΣ", "ος"]] as [$filter, $property]) { $exact["value"] = $filter; self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => $property])); $isNot["value"] = $filter; self::assertTrue(FeatureFlag::matchProperty($isNot, ["key" => $property])); } + + $exact["value"] = "ΟΣ"; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "οσ"])); } public function testMatchPropertyStringificationPreservesIntegralFloats(): void From 496518d9fcbab75aa9aba42d574604c7d73427ec Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 08:07:24 +0200 Subject: [PATCH 4/6] fix(flags): support Unicode lowercase without mbstring --- lib/FeatureFlag.php | 9 ++++++++- test/FeatureFlagLocalEvaluationTest.php | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 7344c2d..f3711de 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -571,7 +571,14 @@ private static function computeExactMatch($value, $overrideValue) private static function unicodeLowercase($value) { - return mb_convert_case($value, MB_CASE_LOWER_SIMPLE, "UTF-8"); + // Rust's full, context-independent lowercase expands U+0130, while PHP's + // simple mode does not. Expand it before applying simple lowercase. + $value = str_replace("\u{0130}", "i\u{0307}", $value); + + // The polyfill does not define MB_CASE_LOWER_SIMPLE, but its regular + // lowercase mapping is context-independent and therefore equivalent here. + $mode = defined('MB_CASE_LOWER_SIMPLE') ? MB_CASE_LOWER_SIMPLE : MB_CASE_LOWER; + return mb_convert_case($value, $mode, "UTF-8"); } private static function valueToString($value) diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index ed10f1b..1c9c38d 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -258,6 +258,10 @@ public function testMatchPropertyExactUsesUnicodeLowercase(): void self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "ä"])); + $exact["value"] = "İ"; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "i\u{0307}"])); + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => "i"])); + $exact["value"] = ["FREE", "Ä"]; self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "ä"])); From be566e7ac0494faea9629c80f72a252ba7513cfb Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 08:44:06 +0200 Subject: [PATCH 5/6] fix(deps): polyfill iconv for Unicode matching --- composer.json | 3 +- composer.lock | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index c9a1168..49e2bbf 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "ext-json": "*", "php": ">=8.2", "symfony/clock": "^6.2|^7.0|^8.0", - "symfony/polyfill-mbstring": "^1.31" + "symfony/polyfill-mbstring": "^1.31", + "symfony/polyfill-iconv": "^1.31" }, "require-dev": { "phpunit/phpunit": "^11.0", diff --git a/composer.lock b/composer.lock index 03bb94b..4f1b0a7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fcbecc0a26b21917b79a5a17199e10e5", + "content-hash": "e62591693e048b106dd0dfa5150bd102", "packages": [ { "name": "psr/clock", @@ -132,6 +132,90 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-iconv": "*" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.38.2", From 69f7021a8c7621cab2db87a3111ae978d504d2eb Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 31 Aug 2026 08:17:23 +0200 Subject: [PATCH 6/6] fix(flags): match backend exact property semantics --- .changeset/local-flag-case-folding.md | 2 +- lib/FeatureFlag.php | 190 ++++++++++++++++++++++-- test/FeatureFlagLocalEvaluationTest.php | 88 ++++++++++- 3 files changed, 262 insertions(+), 18 deletions(-) diff --git a/.changeset/local-flag-case-folding.md b/.changeset/local-flag-case-folding.md index bfebaae..ca6068f 100644 --- a/.changeset/local-flag-case-folding.md +++ b/.changeset/local-flag-case-folding.md @@ -2,4 +2,4 @@ "posthog-php": patch --- -Match local feature flag ASCII and Unicode case handling and numeric stringification with the flags service. +Match local feature flag boolean coercion, JSON stringification, numeric formatting, and string case handling with the flags service. diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index f3711de..90c9f14 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -104,7 +104,8 @@ public static function matchProperty($property, $propertyValues) return FeatureFlag::compare($overrideValue, $parsedValue, $operator, "numeric"); } } else { - return FeatureFlag::compare(FeatureFlag::valueToString($overrideValue), FeatureFlag::valueToString($value), $operator); + // Preserve the existing fallback for non-numeric comparison values. + return FeatureFlag::compare(strval($overrideValue), strval($value), $operator); } } @@ -557,8 +558,12 @@ private static function convertToDateTime($value) private static function computeExactMatch($value, $overrideValue) { + if (FeatureFlag::isTruthyOrFalsyPropertyValue($value)) { + return FeatureFlag::isTruthyPropertyValue($value) === FeatureFlag::isTruthyPropertyValue($overrideValue); + } + $overrideString = FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($overrideValue)); - if (is_array($value)) { + if (is_array($value) && array_is_list($value)) { foreach ($value as $candidate) { if (FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($candidate)) === $overrideString) { return true; @@ -569,33 +574,192 @@ private static function computeExactMatch($value, $overrideValue) return FeatureFlag::unicodeLowercase(FeatureFlag::valueToString($value)) === $overrideString; } - private static function unicodeLowercase($value) + private static function isTruthyOrFalsyPropertyValue($value) + { + if (is_bool($value)) { + return true; + } + if (is_string($value)) { + return in_array(strtolower($value), ["true", "false"], true); + } + if (is_array($value) && array_is_list($value)) { + foreach ($value as $candidate) { + if (!FeatureFlag::isTruthyOrFalsyPropertyValue($candidate)) { + return false; + } + } + return true; + } + return false; + } + + private static function isTruthyPropertyValue($value) { - // Rust's full, context-independent lowercase expands U+0130, while PHP's - // simple mode does not. Expand it before applying simple lowercase. - $value = str_replace("\u{0130}", "i\u{0307}", $value); + if (is_bool($value)) { + return $value; + } + if (is_string($value)) { + return strtolower($value) === "true"; + } + if (is_array($value) && array_is_list($value)) { + foreach ($value as $candidate) { + if (!FeatureFlag::isTruthyPropertyValue($candidate)) { + return false; + } + } + return true; + } + return false; + } - // The polyfill does not define MB_CASE_LOWER_SIMPLE, but its regular - // lowercase mapping is context-independent and therefore equivalent here. + private static function unicodeLowercase($value) + { $mode = defined('MB_CASE_LOWER_SIMPLE') ? MB_CASE_LOWER_SIMPLE : MB_CASE_LOWER; + $characters = preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY); + if ($characters === false) { + return mb_convert_case($value, $mode, "UTF-8"); + } + + foreach ($characters as $index => $character) { + if ($character !== "Σ") { + continue; + } + + // PHP 8.2+'s PCRE2 supports the Cased and Case_Ignorable + // derived properties used by Unicode's Final_Sigma rule. + $precededByCased = false; + for ($before = $index - 1; $before >= 0; $before--) { + if (preg_match('/^\p{Case_Ignorable}$/u', $characters[$before])) { + continue; + } + $precededByCased = preg_match('/^\p{Cased}$/u', $characters[$before]) === 1; + break; + } + + $followedByCased = false; + for ($after = $index + 1; $after < count($characters); $after++) { + if (preg_match('/^\p{Case_Ignorable}$/u', $characters[$after])) { + continue; + } + $followedByCased = preg_match('/^\p{Cased}$/u', $characters[$after]) === 1; + break; + } + + $characters[$index] = $precededByCased && !$followedByCased ? "ς" : "σ"; + } + + // U+0130 is the unconditional multi-code-point lowercase mapping that + // PHP's simple mode omits. + $value = str_replace("\u{0130}", "i\u{0307}", implode('', $characters)); return mb_convert_case($value, $mode, "UTF-8"); } private static function valueToString($value) { + if (is_string($value)) { + return $value; + } if (is_bool($value)) { return $value ? "true" : "false"; } + if (is_null($value)) { + return "null"; + } if (is_float($value)) { - if (!is_finite($value)) { - return strval($value); - } - $encoded = json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); - return preg_replace('/\.0(e[+-]?\d+)$/', '$1', $encoded); + return FeatureFlag::floatToString($value); + } + if (is_array($value) || is_object($value)) { + return FeatureFlag::jsonValueToString($value); } return strval($value); } + private static function jsonValueToString($value) + { + $jsonFlags = JSON_UNESCAPED_LINE_TERMINATORS + | JSON_UNESCAPED_SLASHES + | JSON_UNESCAPED_UNICODE + | JSON_THROW_ON_ERROR; + + if (is_string($value)) { + return json_encode($value, $jsonFlags); + } + if (is_bool($value)) { + return $value ? "true" : "false"; + } + if (is_null($value)) { + return "null"; + } + if (is_float($value)) { + return FeatureFlag::floatToString($value); + } + if (is_int($value)) { + return strval($value); + } + if ($value instanceof \JsonSerializable) { + return FeatureFlag::jsonValueToString($value->jsonSerialize()); + } + if (is_object($value)) { + $value = get_object_vars($value); + $isList = false; + } else { + $isList = array_is_list($value); + } + + if ($isList) { + $items = array_map(fn($item) => FeatureFlag::jsonValueToString($item), $value); + return '[' . implode(',', $items) . ']'; + } + + ksort($value, SORT_STRING); + $items = []; + foreach ($value as $key => $item) { + $encodedKey = json_encode(strval($key), $jsonFlags); + $items[] = $encodedKey . ':' . FeatureFlag::jsonValueToString($item); + } + return '{' . implode(',', $items) . '}'; + } + + private static function floatToString($value) + { + if (!is_finite($value)) { + return strval($value); + } + + $encoded = json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); + if ($value == 0.0) { + return str_starts_with($encoded, '-') ? "-0.0" : "0.0"; + } + + preg_match('/^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i', $encoded, $parts); + $sign = $parts[1]; + $integer = $parts[2]; + $fraction = $parts[3] ?? ''; + $encodedExponent = intval($parts[4] ?? 0); + $digits = $integer . $fraction; + $firstNonZero = strspn($digits, '0'); + $digits = rtrim(substr($digits, $firstNonZero), '0'); + $decimalPosition = strlen($integer) + $encodedExponent; + $exponent = $decimalPosition - $firstNonZero - 1; + + if ($exponent >= 16 || $exponent <= -6) { + $mantissa = $digits[0]; + if (strlen($digits) > 1) { + $mantissa .= '.' . substr($digits, 1); + } + return $sign . $mantissa . 'e' . ($exponent >= 0 ? '+' : '') . $exponent; + } + + $position = $exponent + 1; + if ($position <= 0) { + return $sign . '0.' . str_repeat('0', -$position) . $digits; + } + if ($position >= strlen($digits)) { + return $sign . $digits . str_repeat('0', $position - strlen($digits)) . '.0'; + } + return $sign . substr($digits, 0, $position) . '.' . substr($digits, $position); + } + private static function compare($lhs, $rhs, $operator, $type = "string") { // If type is string, we use strcmp to compare the two strings diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 1c9c38d..e567a88 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -269,7 +269,7 @@ public function testMatchPropertyExactUsesUnicodeLowercase(): void $isNot["operator"] = "is_not"; self::assertFalse(FeatureFlag::matchProperty($isNot, ["key" => "ä"])); - foreach ([["ß", "ss"], ["Σ", "ς"], ["ΟΣ", "ος"]] as [$filter, $property]) { + foreach ([["ß", "ss"], ["Σ", "ς"]] as [$filter, $property]) { $exact["value"] = $filter; self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => $property])); @@ -277,8 +277,53 @@ public function testMatchPropertyExactUsesUnicodeLowercase(): void self::assertTrue(FeatureFlag::matchProperty($isNot, ["key" => $property])); } - $exact["value"] = "ΟΣ"; - self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "οσ"])); + foreach ([["ΟΣ", "ος"], ["ΟΔΟΣ", "οδος"], ["ΠΑΡΑΓΓΕΛΙΕΣ", "παραγγελιες"]] as [$filter, $property]) { + $exact["value"] = $filter; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => $property])); + + $isNot["value"] = $filter; + self::assertFalse(FeatureFlag::matchProperty($isNot, ["key" => $property])); + } + + foreach ([["ΟΣ", "οσ"], ["ΟΔΟΣ", "οδοσ"], ["ΠΑΡΑΓΓΕΛΙΕΣ", "παραγγελιεσ"]] as [$filter, $property]) { + $exact["value"] = $filter; + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => $property])); + + $isNot["value"] = $filter; + self::assertTrue(FeatureFlag::matchProperty($isNot, ["key" => $property])); + } + + $exact["value"] = "invalid-\xFF"; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "invalid-\xFF"])); + } + + public function testMatchPropertyExactUsesBackendBooleanCoercion(): void + { + $cases = [ + [false, "banana", true], + ["false", 0, true], + [false, ["a" => true], true], + [true, ["a" => true], false], + [false, new \stdClass(), true], + [["false"], null, true], + [["true", "false"], "true", false], + [["true", "false"], "pro", true], + [[], true, true], + [[], "true", true], + [[], [], true], + [[], [true], true], + [[], false, false], + [[], "banana", false], + [["FREE", "PRO"], "pro", true], + ]; + + foreach ($cases as [$filter, $property, $expected]) { + $exact = ["key" => "key", "value" => $filter, "operator" => "exact"]; + self::assertSame($expected, FeatureFlag::matchProperty($exact, ["key" => $property])); + + $exact["operator"] = "is_not"; + self::assertSame(!$expected, FeatureFlag::matchProperty($exact, ["key" => $property])); + } } public function testMatchPropertyStringificationPreservesIntegralFloats(): void @@ -307,6 +352,41 @@ public function testMatchPropertyStringificationPreservesIntegralFloats(): void } } + public function testMatchPropertyStringificationMatchesBackendJson(): void + { + $cases = [ + ["[1,2]", [1, 2]], + ['{"a":2,"b":1}', ["b" => 1, "a" => 2]], + ['{"outer":{"a":2,"b":1}}', ["outer" => ["b" => 1, "a" => 2]]], + ["1e-7", 1.0e-7], + ["1e-6", 1.0e-6], + ["1.2e-6", 1.2e-6], + ["1e+16", 1.0e16], + ["0.00001", 1.0e-5], + ["0.000099", 9.9e-5], + ]; + + foreach ($cases as [$filter, $property]) { + $exact = ["key" => "key", "value" => $filter, "operator" => "exact"]; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => $property])); + + $exact["operator"] = "is_not"; + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => $property])); + } + + $exact = ["key" => "key", "value" => "Array", "operator" => "exact"]; + self::assertFalse(FeatureFlag::matchProperty($exact, ["key" => [1, 2]])); + + $jsonSerializable = new class implements \JsonSerializable { + public function jsonSerialize(): mixed + { + return ["b" => 1, "a" => 2]; + } + }; + $exact["value"] = '{"a":2,"b":1}'; + self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => $jsonSerializable])); + } + /** * @dataProvider presentPresenceOperatorValuesProvider */ @@ -1254,7 +1334,7 @@ public function testMatchPropertyWithNones(): void "operator" => "is_not" ]; - self::assertTrue(FeatureFlag::matchProperty($prop_a, [ + self::assertFalse(FeatureFlag::matchProperty($prop_a, [ "key" => null, ])); self::assertFalse(FeatureFlag::matchProperty($prop_a, [