Skip to content

Commit 1e4cf21

Browse files
committed
fix(flags): match current backend exact semantics
1 parent 1a4c22d commit 1e4cf21

7 files changed

Lines changed: 1189 additions & 66 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""Generate the Unicode property ranges needed by the Final_Sigma rule."""
3+
4+
from __future__ import annotations
5+
6+
import hashlib
7+
import urllib.request
8+
from pathlib import Path
9+
10+
UNICODE_VERSION = "17.0.0"
11+
FILES = {
12+
"DerivedCoreProperties.txt": "24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08",
13+
"SpecialCasing.txt": "efc25faf19de21b92c1194c111c932e03d2a5eaf18194e33f1156e96de4c9588",
14+
}
15+
OUTPUT = Path(__file__).resolve().parents[1] / "src/PostHog/Json/UnicodeSpecialCasingData.cs"
16+
EXPECTED_RANGE_COUNTS = {"Cased": 175, "Case_Ignorable": 518}
17+
18+
19+
def download(filename: str) -> str:
20+
url = f"https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/{filename}"
21+
contents = urllib.request.urlopen(url).read()
22+
digest = hashlib.sha256(contents).hexdigest()
23+
if digest != FILES[filename]:
24+
raise RuntimeError(f"Unexpected SHA-256 for {url}: {digest}")
25+
return contents.decode("utf-8")
26+
27+
28+
def property_ranges(contents: str, property_name: str) -> list[tuple[int, int]]:
29+
ranges: list[tuple[int, int]] = []
30+
for line in contents.splitlines():
31+
data = line.split("#", 1)[0].strip()
32+
if not data:
33+
continue
34+
fields = [field.strip() for field in data.split(";")]
35+
if len(fields) < 2 or fields[1] != property_name:
36+
continue
37+
bounds = fields[0].split("..")
38+
start = int(bounds[0], 16)
39+
end = int(bounds[-1], 16)
40+
ranges.append((start, end))
41+
return ranges
42+
43+
44+
def validate_ranges(property_name: str, ranges: list[tuple[int, int]]) -> None:
45+
if len(ranges) != EXPECTED_RANGE_COUNTS[property_name]:
46+
raise RuntimeError(f"Unexpected {property_name} range count: {len(ranges)}")
47+
for index, (start, end) in enumerate(ranges):
48+
if start > end or (index > 0 and start <= ranges[index - 1][1]):
49+
raise RuntimeError(f"Invalid {property_name} range at index {index}: {(start, end)}")
50+
51+
52+
def format_ranges(ranges: list[tuple[int, int]]) -> str:
53+
values = [value for bounds in ranges for value in bounds]
54+
lines = []
55+
for index in range(0, len(values), 8):
56+
lines.append(" " + ", ".join(f"0x{value:04X}" for value in values[index:index + 8]) + ",")
57+
return "\n".join(lines)
58+
59+
60+
def main() -> None:
61+
derived = download("DerivedCoreProperties.txt")
62+
special = download("SpecialCasing.txt")
63+
if "0130; 0069 0307; 0130; 0130;" not in special:
64+
raise RuntimeError("Unicode dotted-I lowercase mapping was not found")
65+
if "03A3; 03C2; 03A3; 03A3; Final_Sigma;" not in special:
66+
raise RuntimeError("Unicode Final_Sigma lowercase mapping was not found")
67+
68+
cased = property_ranges(derived, "Cased")
69+
case_ignorable = property_ranges(derived, "Case_Ignorable")
70+
validate_ranges("Cased", cased)
71+
validate_ranges("Case_Ignorable", case_ignorable)
72+
source = f'''// <auto-generated />
73+
// Unicode {UNICODE_VERSION} data generated by bin/generate-unicode-lowercase-data.
74+
// © 2025 Unicode, Inc.
75+
// For terms of use and license, see https://www.unicode.org/terms_of_use.html
76+
// Unicode Data Files and Software License: https://www.unicode.org/license.txt
77+
// Sources:
78+
// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/DerivedCoreProperties.txt
79+
// SHA-256: {FILES["DerivedCoreProperties.txt"]}
80+
// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/SpecialCasing.txt
81+
// SHA-256: {FILES["SpecialCasing.txt"]}
82+
83+
namespace PostHog.Json;
84+
85+
internal static class UnicodeSpecialCasingData
86+
{{
87+
static readonly int[] CasedRanges =
88+
[
89+
{format_ranges(cased)}
90+
];
91+
92+
static readonly int[] CaseIgnorableRanges =
93+
[
94+
{format_ranges(case_ignorable)}
95+
];
96+
97+
internal static bool IsCased(int codePoint) => Contains(CasedRanges, codePoint);
98+
99+
internal static bool IsCaseIgnorable(int codePoint) => Contains(CaseIgnorableRanges, codePoint);
100+
101+
static bool Contains(int[] ranges, int codePoint)
102+
{{
103+
var lower = 0;
104+
var upper = ranges.Length / 2 - 1;
105+
while (lower <= upper)
106+
{{
107+
var middle = lower + (upper - lower) / 2;
108+
var start = ranges[middle * 2];
109+
var end = ranges[middle * 2 + 1];
110+
if (codePoint < start)
111+
{{
112+
upper = middle - 1;
113+
}}
114+
else if (codePoint > end)
115+
{{
116+
lower = middle + 1;
117+
}}
118+
else
119+
{{
120+
return true;
121+
}}
122+
}}
123+
return false;
124+
}}
125+
}}
126+
'''
127+
OUTPUT.write_text(source)
128+
129+
130+
if __name__ == "__main__":
131+
main()

src/PostHog/Features/LocalEvaluator.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -658,10 +658,21 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
658658
throw new InconclusiveMatchException("The filter property value is null");
659659
}
660660

661-
if (overrideValue is null && propertyFilter.Operator != ComparisonOperator.IsNot)
662-
{
663-
// If the value is null, just fail the feature flag comparison. This doesn't throw an
664-
// InconclusiveMatchException because the property value was provided.
661+
if (overrideValue is null
662+
&& propertyFilter.Operator is not (
663+
ComparisonOperator.Exact
664+
or ComparisonOperator.IsNot
665+
or ComparisonOperator.ContainsIgnoreCase
666+
or ComparisonOperator.DoesNotContainIgnoreCase
667+
or ComparisonOperator.StartsWith
668+
or ComparisonOperator.NotStartsWith
669+
or ComparisonOperator.EndsWith
670+
or ComparisonOperator.NotEndsWith
671+
or ComparisonOperator.Regex
672+
or ComparisonOperator.NotRegex))
673+
{
674+
// The backend stringifies null for string operators and exact matching. Other value operators keep the
675+
// existing null non-match behavior.
665676
return false;
666677
}
667678

0 commit comments

Comments
 (0)