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: 5 additions & 0 deletions .changeset/regex-filters-match-flags-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": patch
---

Match `regex` and `not_regex` flag filters locally the way the flags service does. The filter value is now always used as the pattern itself, so patterns containing `/` (such as `^https://example\.com/`) or starting with a bracket (such as `[abc]`) no longer evaluate to `false`, and slash-wrapped values like `/abc/i` are matched literally. Property values are stringified like the other string operators (`null`, booleans, floats, and arrays included), so an array property no longer throws a `TypeError` out of `getFeatureFlag`. Patterns run in Unicode mode, and a regex execution failure is now inconclusive instead of silently making `not_regex` match.
41 changes: 13 additions & 28 deletions lib/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,17 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi
}

if (in_array($operator, ["regex", "not_regex"])) {
$regexValue = FeatureFlag::prepareValueForRegex($value);
if (FeatureFlag::isRegularExpression($regexValue)) {
if ($overrideValue === null) {
return false;
}
$returnValue = preg_match($regexValue, $overrideValue) ? true : false;
if ($operator == "regex") {
return $returnValue;
} else {
return !$returnValue;
}
} else {
$regexValue = FeatureFlag::prepareValueForRegex(FeatureFlag::valueToString($value));
if (!FeatureFlag::isRegularExpression($regexValue)) {
return false;
}

$matched = preg_match($regexValue, FeatureFlag::valueToString($overrideValue));
if ($matched === false) {
throw new InconclusiveMatchException("Regex execution failed: " . preg_last_error_msg());
}

return ($matched === 1) === ($operator == "regex");
}

if (in_array($operator, ["gt", "gte", "lt", "lte"])) {
Expand Down Expand Up @@ -1073,24 +1070,12 @@ private static function isRegularExpression($string)
return $isRegularExpression;
}

private static function prepareValueForRegex($value)
private static function prepareValueForRegex(string $pattern): string
{
$regex = $value;

// If delimiter already exists, do nothing
if (FeatureFlag::isRegularExpression($regex)) {
return $regex;
}

if (substr($regex, 0, 1) != "/") {
$regex = "/" . $regex;
}

if (substr($regex, -1) != "/") {
$regex = $regex . "/";
}
// Escape each delimiter slash that is not already escaped by an odd run of backslashes.
$escaped = preg_replace('~(?<!\\\\)((?:\\\\\\\\)*)/~', '$1\\\\/', $pattern);

return $regex;
return "/" . $escaped . "/u";
}

/**
Expand Down
116 changes: 115 additions & 1 deletion test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,72 @@ public function testMatchPropertyRegex(): void
]));
}

#[DataProvider('regexLikeFlagsServiceProvider')]
public function testMatchPropertyRegexMatchesLikeFlagsService(
string $operator,
$pattern,
$propertyValue,
bool $expected
): void {
$prop = [
"key" => "key",
"value" => $pattern,
"operator" => $operator,
];

self::assertSame($expected, FeatureFlag::matchProperty($prop, [
"key" => $propertyValue,
]));
}

public static function regexLikeFlagsServiceProvider(): array
{
return [
'unescaped slash' => ["regex", "^https://example\\.com/", "https://example.com/pricing", true],
'unescaped slash negated' => ["not_regex", "^https://example\\.com/", "https://example.com/pricing", false],
'slash mid pattern' => ["regex", "a/b", "a/b", true],
'escaped slash' => ["regex", "a\\/b", "a/b", true],
'escaped backslash before slash' => ["regex", "a\\\\/b", "a\\/b", true],
'bracket class' => ["regex", "[abc]", "b", true],
'bracket class negated' => ["not_regex", "[abc]", "b", false],
'parenthesized alternation' => ["regex", "(foo|bar)", "xbarx", true],
'slash-wrapped pattern is literal' => ["regex", "/abc/i", "ABC", false],
'slash-wrapped pattern matches literally' => ["regex", "/abc/i", "x/abc/iy", true],
'empty pattern' => ["regex", "", "[email protected]", true],
'empty pattern negated' => ["not_regex", "", "[email protected]", false],
'invalid pattern' => ["regex", "[", "[email protected]", false],
'invalid pattern negated' => ["not_regex", "[", "[email protected]", false],
'case sensitive' => ["regex", "EXAMPLE", "[email protected]", false],
'inline case flag' => ["regex", "(?i)EXAMPLE", "[email protected]", true],
'unicode word' => ["regex", "^\\w+$", "h茅llo", true],
'unicode dot' => ["regex", "^.$", "茅", true],
'numeric pattern' => ["regex", 12, 123, true],
'boolean property' => ["regex", "^true$", true, true],
'float property' => ["regex", "^1\\.0$", 1.0, true],
'array property' => ["regex", "\\[1,2\\]", [1, 2], true],
'null property' => ["regex", "^null$", null, true],
'null property negated' => ["not_regex", "x", null, true],
];
}

public function testMatchPropertyRegexExecutionFailureIsInconclusive(): void
{
foreach (["regex", "not_regex"] as $operator) {
try {
FeatureFlag::matchProperty([
"key" => "key",
"value" => "x",
"operator" => $operator,
], [
"key" => "\xff",
]);
self::fail("Expected {$operator} to be inconclusive");
} catch (InconclusiveMatchException $exception) {
self::assertInstanceOf(InconclusiveMatchException::class, $exception);
}
}
}

public function testMatchPropertyMathOperators(): void
{
$prop = [
Expand Down Expand Up @@ -1364,7 +1430,7 @@ public function testMatchPropertyWithNones(): void
"value" => "null",
"operator" => "regex"
];
self::assertFalse(FeatureFlag::matchProperty($prop_c, [
self::assertTrue(FeatureFlag::matchProperty($prop_c, [
"key" => null,
]));
self::assertTrue(FeatureFlag::matchProperty($prop_c, [
Expand Down Expand Up @@ -1596,6 +1662,54 @@ public function testFlagDistinctIdPropertyIsAvailableForLocalEvaluationOnly()
$this->checkEmptyErrorLogs();
}

public function testRegexFlagEvaluatesLocallyLikeFlagsService()
{
$localEvaluationResponse = MockedResponses::LOCAL_EVALUATION_SIMPLE_REQUEST;
$localEvaluationResponse['flags'][0]['key'] = 'regex-flag';
$localEvaluationResponse['flags'][0]['filters']['groups'][0]['properties'] = [
[
'key' => 'landing_page',
'operator' => 'regex',
'value' => '^https://example\\.com/pricing',
'type' => 'person',
],
];

$this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: $localEvaluationResponse);
$this->client = new Client(
self::FAKE_API_KEY,
[
"debug" => true,
],
$this->http_client,
"test"
);
PostHog::init(null, null, $this->client);
$this->http_client->calls = array();

$this->assertTrue(PostHog::getFeatureFlag(
'regex-flag',
'some-distinct-id',
[],
['landing_page' => 'https://example.com/pricing?plan=team'],
[],
true,
false
));
$this->assertFalse(PostHog::getFeatureFlag(
'regex-flag',
'some-distinct-id',
[],
['landing_page' => ['https://example.com/blog']],
[],
true,
false
));
$this->assertEquals(array(), $this->http_client->calls);

$this->checkEmptyErrorLogs();
}

public function testFlagPersonBooleanProperties()
{
$this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_BOOLEAN_REQUEST);
Expand Down
Loading