diff --git a/.changeset/starts-with-ends-with-operators.md b/.changeset/starts-with-ends-with-operators.md new file mode 100644 index 0000000..29abf1d --- /dev/null +++ b/.changeset/starts-with-ends-with-operators.md @@ -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. + +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. diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index b82a4fb..55f6e4c 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -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))); + } + + 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)) { @@ -157,7 +173,7 @@ public static function matchProperty($property, $propertyValues) && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; } - return false; + throw new InconclusiveMatchException("Unknown operator: " . $operator); } /** diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index cb9cc89..ee7064b 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -333,6 +333,246 @@ public function testMatchPropertyContains(): void ])); } + public function testMatchPropertyStartsWith(): void + { + $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 = [