diff --git a/.sampo/changesets/pompous-guardian-joukahainen.md b/.sampo/changesets/pompous-guardian-joukahainen.md new file mode 100644 index 000000000..e90ec2462 --- /dev/null +++ b/.sampo/changesets/pompous-guardian-joukahainen.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Fix local feature flag string and regex operators to match an explicit null property as "null", like the flags service. This can change flag values or selected variants for explicitly null person or group properties. Missing properties still allow remote evaluation fallback. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 7545306ea..049e2fe4e 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -698,6 +698,7 @@ def match_property( if ( operator not in NONE_VALUES_ALLOWED_OPERATORS + and operator not in STRING_OPERATORS and operator != "exact" and override_value is None ): @@ -750,17 +751,12 @@ def compute_exact_match(value, override_value): return not matched return matched - if operator == "regex": - return ( - is_valid_regex(str(value)) - and re.compile(str(value)).search(str(override_value)) is not None - ) - - if operator == "not_regex": - return ( - is_valid_regex(str(value)) - and re.compile(str(value)).search(str(override_value)) is None - ) + if operator in ("regex", "not_regex"): + if not is_valid_regex(str(value)): + return False + property_string = "null" if override_value is None else str(override_value) + matched = re.compile(str(value)).search(property_string) is not None + return not matched if operator == "not_regex" else matched if operator in ["is_date_before", "is_date_after"]: try: diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 374cb3259..2c430336d 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5654,6 +5654,41 @@ def test_string_operators_preserve_float_stringification( prop = self.property("key", value, operator) self.assertTrue(match_property(prop, {"key": property_value})) + @parameterized.expand( + [ + ("icontains", "nul", True), + ("icontains", "x", False), + ("not_icontains", "x", True), + ("not_icontains", "nul", False), + ("starts_with", "NU", True), + ("starts_with", "x", False), + ("not_starts_with", "x", True), + ("not_starts_with", "nu", False), + ("ends_with", "LL", True), + ("ends_with", "x", False), + ("not_ends_with", "x", True), + ("not_ends_with", "ll", False), + ("regex", "^nu", True), + ("regex", "Nu", False), + ("regex", "^None$", False), + ("regex", "x", False), + ("not_regex", "x", True), + ("not_regex", "^nu", False), + ("not_regex", "^None$", True), + ("regex", "[", False), + ("not_regex", "[", False), + ] + ) + def test_string_operators_match_explicit_null(self, operator, value, expected): + prop = self.property("key", value, operator) + self.assertEqual(expected, match_property(prop, {"key": None})) + + def test_string_operators_with_omitted_key_are_inconclusive(self): + for operator in posthog.feature_flags.STRING_OPERATORS: + with self.subTest(operator=operator): + with self.assertRaises(InconclusiveMatchError): + match_property(self.property("key", "x", operator), {}) + def test_match_properties_regex(self): property_a = self.property(key="key", value=r"\.com$", operator="regex") self.assertTrue(match_property(property_a, {"key": "value.com"}))