Skip to content
Open
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: 5 additions & 0 deletions .changeset/local-flag-case-folding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": patch
---

Match local feature flag ASCII and Unicode case handling and numeric stringification with the flags service.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
"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",
"symfony/polyfill-iconv": "^1.31"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
Expand Down
171 changes: 170 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 29 additions & 5 deletions lib/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -557,19 +557,43 @@ 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)
{
// 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)
{
if (is_bool($value)) {
return $value ? "true" : "false";
} else {
return strval($value);
}
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 strval($value);
}

private static function compare($lhs, $rhs, $operator, $type = "string")
Expand Down
77 changes: 77 additions & 0 deletions test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,65 @@ public function testMatchPropertyNotIn(): void
]);
}

public function testMatchPropertyExactUsesUnicodeLowercase(): void
{
$exact = [
"key" => "key",
"value" => "Ä",
"operator" => "exact",
];

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" => "ä"]));

$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]));
}

$exact["value"] = "ΟΣ";
self::assertTrue(FeatureFlag::matchProperty($exact, ["key" => "οσ"]));
}

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]));

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]));
}
}

/**
* @dataProvider presentPresenceOperatorValuesProvider
*/
Expand Down Expand Up @@ -352,6 +411,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
Expand Down
Loading