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
7 changes: 7 additions & 0 deletions .changeset/starts-with-ends-with-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"posthog-php": minor
---

Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`. Previously, local evaluation treated these operators as unrecognized and silently evaluated their conditions to `false`; they now match correctly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous silent-false behavior still applies to any future operator the server adds: matchProperty() reaches its final return false, so the condition can produce an incorrect local result without triggering remote fallback. The other SDK implementations generally treat an unknown operator as inconclusive and fall back to /flags. Could we change the unknown-operator path to throw InconclusiveMatchException and add a regression test using something like future_operator? This is pre-existing behavior, but this PR demonstrates the forward-compatibility risk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!


Operators local evaluation doesn't recognize now throw `InconclusiveMatchException`, deferring the flag to the `/flags` endpoint instead of producing a silently wrong `false` — so operators the server adds in the future degrade gracefully.
18 changes: 17 additions & 1 deletion lib/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ public static function matchProperty($property, $propertyValues)
return strpos(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))) == false;
}

if ($operator == "starts_with") {
return str_starts_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strtolower() performs ASCII-only case conversion, so international values can differ from the Python SDK’s Unicode casefold() behavior (for example, Äpfel does not start with ä here). The current implementation does match PHP’s existing icontains convention and the authoritative flags service, which uses ASCII-only lowering. Would you prefer server/PHP consistency as implemented, or literal Python SDK parity for Unicode values? Either choice seems defensible, but a non-ASCII test or documentation note would make the intended contract explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to match existing PHP conventions. In all the SDKs, I matched local conventions.

I think we may want to try and match what happens on the server as closely as possible. I opened a separate issue on that: PostHog/posthog#78019

I'd rather do that as an all or nothing follow-up.

}

if ($operator == "not_starts_with") {
return !str_starts_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value)));
}

if ($operator == "ends_with") {
return str_ends_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value)));
}

if ($operator == "not_ends_with") {
return !str_ends_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value)));
}

if (in_array($operator, ["regex", "not_regex"])) {
$regexValue = FeatureFlag::prepareValueForRegex($value);
if (FeatureFlag::isRegularExpression($regexValue)) {
Expand Down Expand Up @@ -157,7 +173,7 @@ public static function matchProperty($property, $propertyValues)
&& FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0;
}

return false;
throw new InconclusiveMatchException("Unknown operator: " . $operator);
}

/**
Expand Down
240 changes: 240 additions & 0 deletions test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,246 @@ public function testMatchPropertyContains(): void
]));
}

public function testMatchPropertyStartsWith(): void
Comment thread
haacked marked this conversation as resolved.
{
$prop = [
"key" => "key",
"value" => "Val",
"operator" => "starts_with"
];

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "value",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "VALUE",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "vaLue4",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "prevalue",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "Alakazam",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => 123,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => null,
]));

$prop = [
"key" => "key",
"value" => 3,
"operator" => "starts_with"
];

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "3",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => 323,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => 123,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "val3",
]));

$prop = [
"key" => "key",
"value" => "Val",
"operator" => "not_starts_with"
];

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "value",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "VALUE",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "prevalue",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "Alakazam",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => null,
]));

// Case folding is ASCII-only, mirroring the flags service: non-ASCII
// characters do not match across case.
$prop = [
"key" => "key",
"value" => "ä",
"operator" => "starts_with"
];

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "ÄBC",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "äbc",
]));
}

public function testMatchPropertyEndsWith(): void
{
$prop = [
"key" => "key",
"value" => "lUe",
"operator" => "ends_with"
];

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "value",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "VALUE",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "343tfvalue",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "value2",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "Alakazam",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => 123,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => null,
]));

$prop = [
"key" => "key",
"value" => 3,
"operator" => "ends_with"
];

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "3",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => 323,
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => 13,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => 321,
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "3val",
]));

$prop = [
"key" => "key",
"value" => "lUe",
"operator" => "not_ends_with"
];

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "value",
]));

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "VALUE",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "value2",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "Alakazam",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => null,
]));

// Case folding is ASCII-only, mirroring the flags service: non-ASCII
// characters do not match across case.
$prop = [
"key" => "key",
"value" => "é",
"operator" => "ends_with"
];

self::assertFalse(FeatureFlag::matchProperty($prop, [
"key" => "CAFÉ",
]));

self::assertTrue(FeatureFlag::matchProperty($prop, [
"key" => "café",
]));
}

public function testMatchPropertyUnknownOperatorIsInconclusive(): void
{
$prop = [
"key" => "key",
"value" => "value",
"operator" => "future_operator"
];

$this->expectException(InconclusiveMatchException::class);
FeatureFlag::matchProperty($prop, [
"key" => "value",
]);
}

public function testMatchPropertyStartsWithEndsWithMissingKeyIsInconclusive(): void
{
foreach (["starts_with", "not_starts_with", "ends_with", "not_ends_with"] as $operator) {
$prop = [
"key" => "key",
"value" => "Val",
"operator" => $operator
];

try {
FeatureFlag::matchProperty($prop, [
"key2" => "value",
]);
self::fail("Expected InconclusiveMatchException for operator {$operator}");
} catch (InconclusiveMatchException $exception) {
self::assertInstanceOf(InconclusiveMatchException::class, $exception);
}
}
}

public function testMatchPropertyRegex(): void
{
$prop = [
Expand Down
Loading