diff --git a/.changeset/case-folding-parity.md b/.changeset/case-folding-parity.md new file mode 100644 index 00000000..a200ec84 --- /dev/null +++ b/.changeset/case-folding-parity.md @@ -0,0 +1,6 @@ +--- +"PostHog": patch +"PostHog.AspNetCore": patch +--- + +Match local feature flag string operators using the same ASCII and Unicode lowercase rules as the flags service. diff --git a/bin/generate-unicode-lowercase-data b/bin/generate-unicode-lowercase-data new file mode 100755 index 00000000..ec1334ba --- /dev/null +++ b/bin/generate-unicode-lowercase-data @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Generate the Unicode property ranges needed by the Final_Sigma rule.""" + +from __future__ import annotations + +import hashlib +import urllib.request +from pathlib import Path + +UNICODE_VERSION = "17.0.0" +FILES = { + "DerivedCoreProperties.txt": "24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08", + "SpecialCasing.txt": "efc25faf19de21b92c1194c111c932e03d2a5eaf18194e33f1156e96de4c9588", +} +OUTPUT = Path(__file__).resolve().parents[1] / "src/PostHog/Json/UnicodeSpecialCasingData.cs" +EXPECTED_RANGE_COUNTS = {"Cased": 175, "Case_Ignorable": 518} + + +def download(filename: str) -> str: + url = f"https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/{filename}" + contents = urllib.request.urlopen(url).read() + digest = hashlib.sha256(contents).hexdigest() + if digest != FILES[filename]: + raise RuntimeError(f"Unexpected SHA-256 for {url}: {digest}") + return contents.decode("utf-8") + + +def property_ranges(contents: str, property_name: str) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + for line in contents.splitlines(): + data = line.split("#", 1)[0].strip() + if not data: + continue + fields = [field.strip() for field in data.split(";")] + if len(fields) < 2 or fields[1] != property_name: + continue + bounds = fields[0].split("..") + start = int(bounds[0], 16) + end = int(bounds[-1], 16) + ranges.append((start, end)) + return ranges + + +def validate_ranges(property_name: str, ranges: list[tuple[int, int]]) -> None: + if len(ranges) != EXPECTED_RANGE_COUNTS[property_name]: + raise RuntimeError(f"Unexpected {property_name} range count: {len(ranges)}") + for index, (start, end) in enumerate(ranges): + if start > end or (index > 0 and start <= ranges[index - 1][1]): + raise RuntimeError(f"Invalid {property_name} range at index {index}: {(start, end)}") + + +def format_ranges(ranges: list[tuple[int, int]]) -> str: + values = [value for bounds in ranges for value in bounds] + lines = [] + for index in range(0, len(values), 8): + lines.append(" " + ", ".join(f"0x{value:04X}" for value in values[index:index + 8]) + ",") + return "\n".join(lines) + + +def main() -> None: + derived = download("DerivedCoreProperties.txt") + special = download("SpecialCasing.txt") + if "0130; 0069 0307; 0130; 0130;" not in special: + raise RuntimeError("Unicode dotted-I lowercase mapping was not found") + if "03A3; 03C2; 03A3; 03A3; Final_Sigma;" not in special: + raise RuntimeError("Unicode Final_Sigma lowercase mapping was not found") + + cased = property_ranges(derived, "Cased") + case_ignorable = property_ranges(derived, "Case_Ignorable") + validate_ranges("Cased", cased) + validate_ranges("Case_Ignorable", case_ignorable) + source = f'''// +// Unicode {UNICODE_VERSION} data generated by bin/generate-unicode-lowercase-data. +// © 2025 Unicode, Inc. +// For terms of use and license, see https://www.unicode.org/terms_of_use.html +// Unicode Data Files and Software License: https://www.unicode.org/license.txt +// Sources: +// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/DerivedCoreProperties.txt +// SHA-256: {FILES["DerivedCoreProperties.txt"]} +// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/SpecialCasing.txt +// SHA-256: {FILES["SpecialCasing.txt"]} + +namespace PostHog.Json; + +internal static class UnicodeSpecialCasingData +{{ + static readonly int[] CasedRanges = + [ +{format_ranges(cased)} + ]; + + static readonly int[] CaseIgnorableRanges = + [ +{format_ranges(case_ignorable)} + ]; + + internal static bool IsCased(int codePoint) => Contains(CasedRanges, codePoint); + + internal static bool IsCaseIgnorable(int codePoint) => Contains(CaseIgnorableRanges, codePoint); + + static bool Contains(int[] ranges, int codePoint) + {{ + var lower = 0; + var upper = ranges.Length / 2 - 1; + while (lower <= upper) + {{ + var middle = lower + (upper - lower) / 2; + var start = ranges[middle * 2]; + var end = ranges[middle * 2 + 1]; + if (codePoint < start) + {{ + upper = middle - 1; + }} + else if (codePoint > end) + {{ + lower = middle + 1; + }} + else + {{ + return true; + }} + }} + return false; + }} +}} +''' + OUTPUT.write_text(source) + + +if __name__ == "__main__": + main() diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index 88cd5b79..446c359d 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -658,10 +658,21 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary< throw new InconclusiveMatchException("The filter property value is null"); } - if (overrideValue is null && propertyFilter.Operator != ComparisonOperator.IsNot) - { - // If the value is null, just fail the feature flag comparison. This doesn't throw an - // InconclusiveMatchException because the property value was provided. + if (overrideValue is null + && propertyFilter.Operator is not ( + ComparisonOperator.Exact + or ComparisonOperator.IsNot + or ComparisonOperator.ContainsIgnoreCase + or ComparisonOperator.DoesNotContainIgnoreCase + or ComparisonOperator.StartsWith + or ComparisonOperator.NotStartsWith + or ComparisonOperator.EndsWith + or ComparisonOperator.NotEndsWith + or ComparisonOperator.Regex + or ComparisonOperator.NotRegex)) + { + // The backend stringifies null for string operators and exact matching. Other value operators keep the + // existing null non-match behavior. return false; } @@ -673,12 +684,12 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary< ComparisonOperator.GreaterThanOrEquals => value <= overrideValue, ComparisonOperator.LessThan => value > overrideValue, ComparisonOperator.LessThanOrEquals => value >= overrideValue, - ComparisonOperator.ContainsIgnoreCase => value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase), - ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase), - ComparisonOperator.StartsWith => value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase), - ComparisonOperator.NotStartsWith => !value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase), - ComparisonOperator.EndsWith => value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase), - ComparisonOperator.NotEndsWith => !value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase), + ComparisonOperator.ContainsIgnoreCase => value.IsContainedByAsciiIgnoreCase(overrideValue), + ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedByAsciiIgnoreCase(overrideValue), + ComparisonOperator.StartsWith => value.IsPrefixOfAsciiIgnoreCase(overrideValue), + ComparisonOperator.NotStartsWith => !value.IsPrefixOfAsciiIgnoreCase(overrideValue), + ComparisonOperator.EndsWith => value.IsSuffixOfAsciiIgnoreCase(overrideValue), + ComparisonOperator.NotEndsWith => !value.IsSuffixOfAsciiIgnoreCase(overrideValue), ComparisonOperator.Regex => value.IsRegexMatch(overrideValue), ComparisonOperator.NotRegex => !value.IsRegexMatch(overrideValue), ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()), diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs index 62a6adb8..6f2db5f0 100644 --- a/src/PostHog/Json/PropertyFilterValue.cs +++ b/src/PostHog/Json/PropertyFilterValue.cs @@ -1,5 +1,8 @@ +using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -21,6 +24,7 @@ namespace PostHog.Json; public class PropertyFilterValue { readonly IReadOnlyList? _numericListValues; + readonly bool? _booleanListValue; /// /// If this value is a string, this property will be set. @@ -50,8 +54,12 @@ public class PropertyFilterValue jsonElement.ValueKind switch { JsonValueKind.String => jsonElement.GetString() is { } stringValue ? new PropertyFilterValue(stringValue) : null, - JsonValueKind.Array when TryParseStringArray(jsonElement, out var stringArrayValue, out var numericListValues) - => new PropertyFilterValue(stringArrayValue, numericListValues), + JsonValueKind.Array when TryParseStringArray( + jsonElement, + out var stringArrayValue, + out var numericListValues, + out var booleanListValue) + => new PropertyFilterValue(stringArrayValue, numericListValues, booleanListValue), JsonValueKind.Number => new PropertyFilterValue(jsonElement.GetInt64()), JsonValueKind.True => new PropertyFilterValue(true), JsonValueKind.False => new PropertyFilterValue(false), @@ -66,13 +74,18 @@ JsonValueKind.Array when TryParseStringArray(jsonElement, out var stringArrayVal /// The list of string values to match against. public PropertyFilterValue(IReadOnlyList listOfStrings) { - ListOfStrings = listOfStrings; + ListOfStrings = NotNull(listOfStrings); + _booleanListValue = TryGetBooleanListValue(ListOfStrings); } - PropertyFilterValue(IReadOnlyList listOfStrings, IReadOnlyList? numericListValues) + PropertyFilterValue( + IReadOnlyList listOfStrings, + IReadOnlyList? numericListValues, + bool? booleanListValue) { ListOfStrings = listOfStrings; _numericListValues = numericListValues; + _booleanListValue = booleanListValue; } /// @@ -115,11 +128,6 @@ public PropertyFilterValue(bool booleanValue) /// trueIf the current value is a valid regex and it matches the other value. public bool IsRegexMatch(object? input) { - if (input is null) - { - return false; - } - if (StringValue is null || !RegexHelpers.TryValidateRegex(StringValue, out var regex, RegexOptions.None)) { return false; @@ -161,6 +169,21 @@ public bool IsSuffixOf(object? other, StringComparison stringComparison) => && StringValue is not null && comparandString.EndsWith(StringValue, stringComparison); + internal bool IsContainedByAsciiIgnoreCase(object? other) => + ToInvariantString(other) is { } comparandString + && StringValue is not null + && ToAsciiLowercase(comparandString).Contains(ToAsciiLowercase(StringValue), StringComparison.Ordinal); + + internal bool IsPrefixOfAsciiIgnoreCase(object? other) => + ToInvariantString(other) is { } comparandString + && StringValue is not null + && ToAsciiLowercase(comparandString).StartsWith(ToAsciiLowercase(StringValue), StringComparison.Ordinal); + + internal bool IsSuffixOfAsciiIgnoreCase(object? other) => + ToInvariantString(other) is { } comparandString + && StringValue is not null + && ToAsciiLowercase(comparandString).EndsWith(ToAsciiLowercase(StringValue), StringComparison.Ordinal); + /// /// Determines whether the specified is an "exact" match for this instance. /// If this instance is an array, then it's checking to see if the value is in the array. @@ -169,20 +192,40 @@ public bool IsSuffixOf(object? other, StringComparison stringComparison) => /// true if the override value is an "exact" match for this value. public bool IsExactMatch(object? overrideValue) { + if (TryGetBooleanValue(out var booleanValue)) + { + return booleanValue == IsTruthyPropertyValue(overrideValue); + } + return this switch { { ListOfStrings: { } listOfStrings } => IsExactListMatch(listOfStrings, _numericListValues, overrideValue), - { StringValue: { } stringValue } => stringValue.Equals(ToInvariantString(overrideValue), StringComparison.OrdinalIgnoreCase), - { BooleanValue: { } booleanValue } => overrideValue switch - { - bool boolOverride => booleanValue == boolOverride, - string stringOverride => booleanValue.ToString().Equals(stringOverride, StringComparison.OrdinalIgnoreCase), - _ => false - }, + { StringValue: { } stringValue } => UnicodeLowercaseEquals(stringValue, ToInvariantString(overrideValue)), _ => false }; } + bool TryGetBooleanValue(out bool value) + { + if (BooleanValue is { } booleanValue) + { + value = booleanValue; + return true; + } + if (StringValue is { } stringValue && TryParseBoolean(stringValue, out value)) + { + return true; + } + if (_booleanListValue is { } booleanListValue) + { + value = booleanListValue; + return true; + } + + value = false; + return false; + } + static bool IsExactListMatch( IReadOnlyList values, IReadOnlyList? numericValues, @@ -194,7 +237,7 @@ static bool IsExactListMatch( } var stringValue = ToInvariantString(overrideValue); - if (stringValue is not null && values.Contains(stringValue, StringComparer.OrdinalIgnoreCase)) + if (stringValue is not null && values.Any(value => UnicodeLowercaseEquals(value, stringValue))) { return true; } @@ -221,10 +264,509 @@ or TypeCode.SByte or TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64 }; } - // Override values must stringify with the invariant culture ("3.14", never "3,14") to match how the - // PostHog flags service stringifies values. Null stays null so null overrides never match string filters. + // Override values use the same compact JSON representation as serde_json::Value::to_string in the flags service. + // Strings remain unquoted because the service returns their contents directly. static string? ToInvariantString(object? value) => - value is null ? null : Convert.ToString(value, CultureInfo.InvariantCulture); + ToInvariantString(value, new HashSet(ReferenceEqualityComparer.Instance), depth: 0); + + static string? ToInvariantString(object? value, HashSet ancestors, int depth) => value switch + { + null => "null", + string stringValue => stringValue, + char character => character.ToString(), + bool booleanValue => booleanValue ? "true" : "false", + double doubleValue => StringifyFloatingPoint(doubleValue), + float floatValue => StringifyFloatingPoint(floatValue), + JsonDocument document => StringifyJsonElement(document.RootElement), + JsonElement element => StringifyJsonElement(element), + IDictionary dictionary => StringifyDictionary(dictionary, ancestors, depth), + IEnumerable enumerable => StringifyArray(enumerable, ancestors, depth), + _ => Convert.ToString(value, CultureInfo.InvariantCulture) + }; + + static string StringifyFloatingPoint(double value) => + !double.IsNaN(value) && !double.IsInfinity(value) + ? StringifyFiniteFloatingPoint(value.ToString("R", CultureInfo.InvariantCulture)) + : value.ToString("R", CultureInfo.InvariantCulture); + + static string StringifyFloatingPoint(float value) => + !float.IsNaN(value) && !float.IsInfinity(value) + ? StringifyFiniteFloatingPoint(value.ToString("R", CultureInfo.InvariantCulture)) + : value.ToString("R", CultureInfo.InvariantCulture); + + static string StringifyFiniteFloatingPoint(string roundTripValue) + { + var isNegative = roundTripValue.Length > 0 && roundTripValue[0] == '-'; + var unsignedValue = isNegative ? roundTripValue.Substring(1) : roundTripValue; + var exponentMarker = unsignedValue.IndexOfAny(['E', 'e']); + var significand = exponentMarker >= 0 ? unsignedValue.Substring(0, exponentMarker) : unsignedValue; + var explicitExponent = exponentMarker >= 0 + ? ParseExponent(unsignedValue, exponentMarker + 1) + : 0; + var decimalPoint = significand.IndexOfAny(['.']); + if (decimalPoint < 0) + { + decimalPoint = significand.Length; + } + + var untrimmedDigits = decimalPoint < significand.Length + ? significand.Remove(decimalPoint, 1) + : significand; + var firstNonZero = 0; + while (firstNonZero < untrimmedDigits.Length && untrimmedDigits[firstNonZero] == '0') + { + firstNonZero++; + } + if (firstNonZero == untrimmedDigits.Length) + { + return isNegative ? "-0.0" : "0.0"; + } + + var exponent = explicitExponent + decimalPoint - firstNonZero - 1; + var digits = untrimmedDigits.Substring(firstNonZero).TrimEnd('0'); + var sign = isNegative ? "-" : string.Empty; + if (exponent <= -6 || exponent >= 16) + { + var fraction = digits.Length > 1 ? $".{digits.Substring(1)}" : string.Empty; + var exponentSign = exponent >= 0 ? "+" : string.Empty; + return $"{sign}{digits[0]}{fraction}e{exponentSign}{exponent}"; + } + + var output = new StringBuilder(sign); + if (exponent < 0) + { + output.Append("0."); + output.Append('0', -exponent - 1); + output.Append(digits); + return output.ToString(); + } + + var integerLength = exponent + 1; + if (digits.Length <= integerLength) + { + output.Append(digits); + output.Append('0', integerLength - digits.Length); + output.Append(".0"); + return output.ToString(); + } + + output.Append(digits, 0, integerLength); + output.Append('.'); + output.Append(digits, integerLength, digits.Length - integerLength); + return output.ToString(); + } + + static int ParseExponent(string value, int startIndex) + { + var isNegative = value[startIndex] == '-'; + var index = value[startIndex] is '-' or '+' ? startIndex + 1 : startIndex; + var exponent = 0; + while (index < value.Length) + { + exponent = exponent * 10 + value[index] - '0'; + index++; + } + return isNegative ? -exponent : exponent; + } + + static string StringifyJsonElement(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Number when element.TryGetInt64(out var integer) => + integer.ToString(CultureInfo.InvariantCulture), + JsonValueKind.Number when element.TryGetUInt64(out var unsignedInteger) => + unsignedInteger.ToString(CultureInfo.InvariantCulture), + JsonValueKind.Number => StringifyFloatingPoint(element.GetDouble()), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Array => StringifyJsonArray(element), + JsonValueKind.Object => StringifyJsonObject(element), + _ => element.GetRawText() + }; + + static string StringifyJsonArray(JsonElement element) + { + var output = new StringBuilder("["); + var first = true; + foreach (var item in element.EnumerateArray()) + { + if (!first) + { + output.Append(','); + } + AppendJsonValue(output, item); + first = false; + } + return output.Append(']').ToString(); + } + + static string StringifyJsonObject(JsonElement element) + { + var properties = new SortedDictionary(Utf8StringComparer.Instance); + foreach (var property in element.EnumerateObject()) + { + properties[property.Name] = property.Value; + } + + var output = new StringBuilder("{"); + var first = true; + foreach (var property in properties) + { + if (!first) + { + output.Append(','); + } + AppendJsonString(output, property.Key); + output.Append(':'); + AppendJsonValue(output, property.Value); + first = false; + } + return output.Append('}').ToString(); + } + + static string? StringifyDictionary(IDictionary dictionary, HashSet ancestors, int depth) + { + if (depth >= 64 || !ancestors.Add(dictionary)) + { + return null; + } + + try + { + var properties = new SortedDictionary(Utf8StringComparer.Instance); + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key is not string key) + { + return null; + } + properties[key] = entry.Value; + } + + var output = new StringBuilder("{"); + var first = true; + foreach (var property in properties) + { + if (!first) + { + output.Append(','); + } + AppendJsonString(output, property.Key); + output.Append(':'); + if (!AppendJsonValue(output, property.Value, ancestors, depth + 1)) + { + return null; + } + first = false; + } + return output.Append('}').ToString(); + } + finally + { + ancestors.Remove(dictionary); + } + } + + static string? StringifyArray(IEnumerable values, HashSet ancestors, int depth) + { + if (depth >= 64 || !ancestors.Add(values)) + { + return null; + } + + try + { + var output = new StringBuilder("["); + var first = true; + foreach (var value in values) + { + if (!first) + { + output.Append(','); + } + if (!AppendJsonValue(output, value, ancestors, depth + 1)) + { + return null; + } + first = false; + } + return output.Append(']').ToString(); + } + finally + { + ancestors.Remove(values); + } + } + + static void AppendJsonValue(StringBuilder output, object? value) + { + if (value is string stringValue) + { + AppendJsonString(output, stringValue); + return; + } + if (value is char character) + { + AppendJsonString(output, character.ToString()); + return; + } + if (value is JsonElement { ValueKind: JsonValueKind.String } stringElement) + { + AppendJsonString(output, stringElement.GetString() ?? string.Empty); + return; + } + + output.Append(ToInvariantString(value)); + } + + static bool AppendJsonValue( + StringBuilder output, + object? value, + HashSet ancestors, + int depth) + { + if (value is string stringValue) + { + AppendJsonString(output, stringValue); + return true; + } + if (value is char character) + { + AppendJsonString(output, character.ToString()); + return true; + } + if (value is JsonElement { ValueKind: JsonValueKind.String } stringElement) + { + AppendJsonString(output, stringElement.GetString() ?? string.Empty); + return true; + } + if (value is JsonDocument { RootElement.ValueKind: JsonValueKind.String } stringDocument) + { + AppendJsonString(output, stringDocument.RootElement.GetString() ?? string.Empty); + return true; + } + + var stringifiedValue = ToInvariantString(value, ancestors, depth); + if (stringifiedValue is null) + { + return false; + } + output.Append(stringifiedValue); + return true; + } + + static void AppendJsonString(StringBuilder output, string value) + { + output.Append('"'); + foreach (var character in value) + { + switch (character) + { + case '"': output.Append("\\\""); break; + case '\\': output.Append("\\\\"); break; + case '\b': output.Append("\\b"); break; + case '\t': output.Append("\\t"); break; + case '\n': output.Append("\\n"); break; + case '\f': output.Append("\\f"); break; + case '\r': output.Append("\\r"); break; + case < ' ': + output.Append("\\u"); + output.Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); + break; + default: output.Append(character); break; + } + } + output.Append('"'); + } + + static bool TryParseBoolean(string value, out bool result) + { + var lowercaseValue = UnicodeLowercase(value); + if (lowercaseValue == "true") + { + result = true; + return true; + } + if (lowercaseValue == "false") + { + result = false; + return true; + } + + result = false; + return false; + } + + static bool? TryGetBooleanListValue(IEnumerable values) + { + var result = true; + foreach (var value in values) + { + if (!TryParseBoolean(value, out var booleanValue)) + { + return null; + } + result &= booleanValue; + } + return result; + } + + static bool IsTruthyPropertyValue(object? value) => + IsTruthyPropertyValue(value, new HashSet(ReferenceEqualityComparer.Instance), depth: 0); + + static bool IsTruthyPropertyValue(object? value, HashSet ancestors, int depth) => value switch + { + bool booleanValue => booleanValue, + string stringValue => UnicodeLowercase(stringValue) == "true", + JsonDocument document => IsTruthyJsonValue(document.RootElement, depth), + JsonElement element => IsTruthyJsonValue(element, depth), + IDictionary => false, + IEnumerable enumerable => AllTruthy(enumerable, ancestors, depth), + _ => false + }; + + static bool IsTruthyJsonValue(JsonElement value, int depth) => value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.String => UnicodeLowercase(value.GetString() ?? string.Empty) == "true", + JsonValueKind.Array when depth < 64 => + value.EnumerateArray().All(element => IsTruthyJsonValue(element, depth + 1)), + _ => false + }; + + static bool AllTruthy(IEnumerable values, HashSet ancestors, int depth) + { + if (depth >= 64 || !ancestors.Add(values)) + { + return false; + } + + try + { + foreach (var value in values) + { + if (!IsTruthyPropertyValue(value, ancestors, depth + 1)) + { + return false; + } + } + return true; + } + finally + { + ancestors.Remove(values); + } + } + +#pragma warning disable CA1308 // The flags service lowercases both operands; uppercasing has different Unicode semantics. + static bool UnicodeLowercaseEquals(string left, string? right) => + right is not null + && string.Equals(UnicodeLowercase(left), UnicodeLowercase(right), StringComparison.Ordinal); + + // .NET's invariant mapping does not expand dotted-I or apply the exact Unicode Final_Sigma context used by Rust. + // UnicodeSpecialCasingData supplies the exact derived properties used + // by that condition from Unicode 17.0, the version used by the current flags service Rust toolchain. + static string UnicodeLowercase(string value) + { + var expanded = new StringBuilder(value.Length + 1); + for (var index = 0; index < value.Length;) + { + var codePoint = GetCodePoint(value, index, out var codePointLength); + if (codePoint == 0x0130) + { + expanded.Append("i\u0307"); + } + else if (codePoint == 0x03A3) + { + expanded.Append(IsFinalSigma(value, index, codePointLength) ? '\u03C2' : '\u03C3'); + } + else + { + expanded.Append(value, index, codePointLength); + } + index += codePointLength; + } + return expanded.ToString().ToLowerInvariant(); + } + + static bool IsFinalSigma(string value, int index, int codePointLength) => + HasCasedCodePointBefore(value, index) + && !HasCasedCodePointAfter(value, index + codePointLength); + + static bool HasCasedCodePointBefore(string value, int index) + { + while (index > 0) + { + var codePoint = GetPreviousCodePoint(value, ref index); + if (UnicodeSpecialCasingData.IsCased(codePoint)) + { + return true; + } + if (!UnicodeSpecialCasingData.IsCaseIgnorable(codePoint)) + { + return false; + } + } + return false; + } + + static bool HasCasedCodePointAfter(string value, int index) + { + while (index < value.Length) + { + var codePoint = GetCodePoint(value, index, out var codePointLength); + if (UnicodeSpecialCasingData.IsCased(codePoint)) + { + return true; + } + if (!UnicodeSpecialCasingData.IsCaseIgnorable(codePoint)) + { + return false; + } + index += codePointLength; + } + return false; + } + + static int GetCodePoint(string value, int index, out int length) + { + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + length = 2; + return char.ConvertToUtf32(value[index], value[index + 1]); + } + + length = 1; + return value[index]; + } + + static int GetPreviousCodePoint(string value, ref int index) + { + index--; + if (index > 0 && char.IsLowSurrogate(value[index]) && char.IsHighSurrogate(value[index - 1])) + { + index--; + return char.ConvertToUtf32(value[index], value[index + 1]); + } + return value[index]; + } +#pragma warning restore CA1308 + + static string ToAsciiLowercase(string value) + { + var characters = value.ToCharArray(); + for (var index = 0; index < characters.Length; index++) + { + if (characters[index] is >= 'A' and <= 'Z') + { + characters[index] = (char)(characters[index] + ('a' - 'A')); + } + } + return new string(characters); + } static bool TryParseDoubleWithoutUnderflow(string value, out double number) => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number) @@ -541,27 +1083,62 @@ public bool Equals(PropertyFilterValue? other) && BooleanValue == other.BooleanValue; } + sealed class ReferenceEqualityComparer : IEqualityComparer + { + internal static ReferenceEqualityComparer Instance { get; } = new(); + + public new bool Equals(object? left, object? right) => ReferenceEquals(left, right); + + public int GetHashCode(object value) => RuntimeHelpers.GetHashCode(value); + } + + sealed class Utf8StringComparer : IComparer + { + internal static Utf8StringComparer Instance { get; } = new(); + + public int Compare(string? left, string? right) + { + if (ReferenceEquals(left, right)) + { + return 0; + } + if (left is null) + { + return -1; + } + if (right is null) + { + return 1; + } + + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + var sharedLength = Math.Min(leftBytes.Length, rightBytes.Length); + for (var index = 0; index < sharedLength; index++) + { + var comparison = leftBytes[index].CompareTo(rightBytes[index]); + if (comparison != 0) + { + return comparison; + } + } + return leftBytes.Length.CompareTo(rightBytes.Length); + } + } + static bool TryParseStringArray( JsonElement jsonElement, [NotNullWhen(returnValue: true)] out IReadOnlyList? value, - out IReadOnlyList? numericValues) + out IReadOnlyList? numericValues, + out bool? booleanValue) { List values = []; List numbers = []; foreach (var element in jsonElement.EnumerateArray()) { - var stringValue = element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.GetRawText(), - _ => null - }; - if (stringValue is null) - { - value = null; - numericValues = null; - return false; - } + var stringValue = element.ValueKind is JsonValueKind.String + ? element.GetString() ?? string.Empty + : StringifyJsonElement(element); values.Add(stringValue); if (element.ValueKind is JsonValueKind.Number) { @@ -571,6 +1148,31 @@ static bool TryParseStringArray( value = values.ToReadOnlyList(); numericValues = numbers.Count > 0 ? numbers.ToReadOnlyList() : null; + booleanValue = TryGetJsonBooleanValue(jsonElement); return true; } + + static bool? TryGetJsonBooleanValue(JsonElement value) => value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when TryParseBoolean(value.GetString() ?? string.Empty, out var booleanValue) + => booleanValue, + JsonValueKind.Array => TryGetJsonBooleanArrayValue(value), + _ => null + }; + + static bool? TryGetJsonBooleanArrayValue(JsonElement value) + { + var result = true; + foreach (var element in value.EnumerateArray()) + { + if (TryGetJsonBooleanValue(element) is not { } booleanValue) + { + return null; + } + result &= booleanValue; + } + return result; + } } \ No newline at end of file diff --git a/src/PostHog/Json/UnicodeSpecialCasingData.cs b/src/PostHog/Json/UnicodeSpecialCasingData.cs new file mode 100644 index 00000000..dba6d8ba --- /dev/null +++ b/src/PostHog/Json/UnicodeSpecialCasingData.cs @@ -0,0 +1,226 @@ +// +// Unicode 17.0.0 data generated by bin/generate-unicode-lowercase-data. +// © 2025 Unicode, Inc. +// For terms of use and license, see https://www.unicode.org/terms_of_use.html +// Unicode Data Files and Software License: https://www.unicode.org/license.txt +// Sources: +// https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt +// SHA-256: 24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08 +// https://www.unicode.org/Public/17.0.0/ucd/SpecialCasing.txt +// SHA-256: efc25faf19de21b92c1194c111c932e03d2a5eaf18194e33f1156e96de4c9588 + +namespace PostHog.Json; + +internal static class UnicodeSpecialCasingData +{ + static readonly int[] CasedRanges = + [ + 0x0041, 0x005A, 0x0061, 0x007A, 0x00AA, 0x00AA, 0x00B5, 0x00B5, + 0x00BA, 0x00BA, 0x00C0, 0x00D6, 0x00D8, 0x00F6, 0x00F8, 0x01BA, + 0x01BC, 0x01BF, 0x01C4, 0x0293, 0x0296, 0x02AF, 0x02B0, 0x02B8, + 0x02C0, 0x02C1, 0x02E0, 0x02E4, 0x0345, 0x0345, 0x0370, 0x0373, + 0x0376, 0x0377, 0x037A, 0x037A, 0x037B, 0x037D, 0x037F, 0x037F, + 0x0386, 0x0386, 0x0388, 0x038A, 0x038C, 0x038C, 0x038E, 0x03A1, + 0x03A3, 0x03F5, 0x03F7, 0x0481, 0x048A, 0x052F, 0x0531, 0x0556, + 0x0560, 0x0588, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, + 0x10D0, 0x10FA, 0x10FC, 0x10FC, 0x10FD, 0x10FF, 0x13A0, 0x13F5, + 0x13F8, 0x13FD, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, + 0x1D00, 0x1D2B, 0x1D2C, 0x1D6A, 0x1D6B, 0x1D77, 0x1D78, 0x1D78, + 0x1D79, 0x1D9A, 0x1D9B, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, + 0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, + 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, + 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, + 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, + 0x1FF6, 0x1FFC, 0x2071, 0x2071, 0x207F, 0x207F, 0x2090, 0x209C, + 0x2102, 0x2102, 0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, + 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, + 0x212A, 0x212D, 0x212F, 0x2134, 0x2139, 0x2139, 0x213C, 0x213F, + 0x2145, 0x2149, 0x214E, 0x214E, 0x2160, 0x217F, 0x2183, 0x2184, + 0x24B6, 0x24E9, 0x2C00, 0x2C7B, 0x2C7C, 0x2C7D, 0x2C7E, 0x2CE4, + 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2D00, 0x2D25, 0x2D27, 0x2D27, + 0x2D2D, 0x2D2D, 0xA640, 0xA66D, 0xA680, 0xA69B, 0xA69C, 0xA69D, + 0xA722, 0xA76F, 0xA770, 0xA770, 0xA771, 0xA787, 0xA78B, 0xA78E, + 0xA790, 0xA7DC, 0xA7F1, 0xA7F4, 0xA7F5, 0xA7F6, 0xA7F8, 0xA7F9, + 0xA7FA, 0xA7FA, 0xAB30, 0xAB5A, 0xAB5C, 0xAB5F, 0xAB60, 0xAB68, + 0xAB69, 0xAB69, 0xAB70, 0xABBF, 0xFB00, 0xFB06, 0xFB13, 0xFB17, + 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, 0x10400, 0x1044F, 0x104B0, 0x104D3, + 0x104D8, 0x104FB, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, + 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1, 0x105B3, 0x105B9, + 0x105BB, 0x105BC, 0x10780, 0x10780, 0x10783, 0x10785, 0x10787, 0x107B0, + 0x107B2, 0x107BA, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2, 0x10D50, 0x10D65, + 0x10D70, 0x10D85, 0x118A0, 0x118DF, 0x16E40, 0x16E7F, 0x16EA0, 0x16EB8, + 0x16EBB, 0x16ED3, 0x1D400, 0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, + 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9, + 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, + 0x1D50D, 0x1D514, 0x1D516, 0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, + 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5, + 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, + 0x1D716, 0x1D734, 0x1D736, 0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, + 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1DF00, 0x1DF09, + 0x1DF0B, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E900, 0x1E943, + 0x1F130, 0x1F149, 0x1F150, 0x1F169, 0x1F170, 0x1F189, + ]; + + static readonly int[] CaseIgnorableRanges = + [ + 0x0027, 0x0027, 0x002E, 0x002E, 0x003A, 0x003A, 0x005E, 0x005E, + 0x0060, 0x0060, 0x00A8, 0x00A8, 0x00AD, 0x00AD, 0x00AF, 0x00AF, + 0x00B4, 0x00B4, 0x00B7, 0x00B7, 0x00B8, 0x00B8, 0x02B0, 0x02C1, + 0x02C2, 0x02C5, 0x02C6, 0x02D1, 0x02D2, 0x02DF, 0x02E0, 0x02E4, + 0x02E5, 0x02EB, 0x02EC, 0x02EC, 0x02ED, 0x02ED, 0x02EE, 0x02EE, + 0x02EF, 0x02FF, 0x0300, 0x036F, 0x0374, 0x0374, 0x0375, 0x0375, + 0x037A, 0x037A, 0x0384, 0x0385, 0x0387, 0x0387, 0x0483, 0x0487, + 0x0488, 0x0489, 0x0559, 0x0559, 0x055F, 0x055F, 0x0591, 0x05BD, + 0x05BF, 0x05BF, 0x05C1, 0x05C2, 0x05C4, 0x05C5, 0x05C7, 0x05C7, + 0x05F4, 0x05F4, 0x0600, 0x0605, 0x0610, 0x061A, 0x061C, 0x061C, + 0x0640, 0x0640, 0x064B, 0x065F, 0x0670, 0x0670, 0x06D6, 0x06DC, + 0x06DD, 0x06DD, 0x06DF, 0x06E4, 0x06E5, 0x06E6, 0x06E7, 0x06E8, + 0x06EA, 0x06ED, 0x070F, 0x070F, 0x0711, 0x0711, 0x0730, 0x074A, + 0x07A6, 0x07B0, 0x07EB, 0x07F3, 0x07F4, 0x07F5, 0x07FA, 0x07FA, + 0x07FD, 0x07FD, 0x0816, 0x0819, 0x081A, 0x081A, 0x081B, 0x0823, + 0x0824, 0x0824, 0x0825, 0x0827, 0x0828, 0x0828, 0x0829, 0x082D, + 0x0859, 0x085B, 0x0888, 0x0888, 0x0890, 0x0891, 0x0897, 0x089F, + 0x08C9, 0x08C9, 0x08CA, 0x08E1, 0x08E2, 0x08E2, 0x08E3, 0x0902, + 0x093A, 0x093A, 0x093C, 0x093C, 0x0941, 0x0948, 0x094D, 0x094D, + 0x0951, 0x0957, 0x0962, 0x0963, 0x0971, 0x0971, 0x0981, 0x0981, + 0x09BC, 0x09BC, 0x09C1, 0x09C4, 0x09CD, 0x09CD, 0x09E2, 0x09E3, + 0x09FE, 0x09FE, 0x0A01, 0x0A02, 0x0A3C, 0x0A3C, 0x0A41, 0x0A42, + 0x0A47, 0x0A48, 0x0A4B, 0x0A4D, 0x0A51, 0x0A51, 0x0A70, 0x0A71, + 0x0A75, 0x0A75, 0x0A81, 0x0A82, 0x0ABC, 0x0ABC, 0x0AC1, 0x0AC5, + 0x0AC7, 0x0AC8, 0x0ACD, 0x0ACD, 0x0AE2, 0x0AE3, 0x0AFA, 0x0AFF, + 0x0B01, 0x0B01, 0x0B3C, 0x0B3C, 0x0B3F, 0x0B3F, 0x0B41, 0x0B44, + 0x0B4D, 0x0B4D, 0x0B55, 0x0B56, 0x0B62, 0x0B63, 0x0B82, 0x0B82, + 0x0BC0, 0x0BC0, 0x0BCD, 0x0BCD, 0x0C00, 0x0C00, 0x0C04, 0x0C04, + 0x0C3C, 0x0C3C, 0x0C3E, 0x0C40, 0x0C46, 0x0C48, 0x0C4A, 0x0C4D, + 0x0C55, 0x0C56, 0x0C62, 0x0C63, 0x0C81, 0x0C81, 0x0CBC, 0x0CBC, + 0x0CBF, 0x0CBF, 0x0CC6, 0x0CC6, 0x0CCC, 0x0CCD, 0x0CE2, 0x0CE3, + 0x0D00, 0x0D01, 0x0D3B, 0x0D3C, 0x0D41, 0x0D44, 0x0D4D, 0x0D4D, + 0x0D62, 0x0D63, 0x0D81, 0x0D81, 0x0DCA, 0x0DCA, 0x0DD2, 0x0DD4, + 0x0DD6, 0x0DD6, 0x0E31, 0x0E31, 0x0E34, 0x0E3A, 0x0E46, 0x0E46, + 0x0E47, 0x0E4E, 0x0EB1, 0x0EB1, 0x0EB4, 0x0EBC, 0x0EC6, 0x0EC6, + 0x0EC8, 0x0ECE, 0x0F18, 0x0F19, 0x0F35, 0x0F35, 0x0F37, 0x0F37, + 0x0F39, 0x0F39, 0x0F71, 0x0F7E, 0x0F80, 0x0F84, 0x0F86, 0x0F87, + 0x0F8D, 0x0F97, 0x0F99, 0x0FBC, 0x0FC6, 0x0FC6, 0x102D, 0x1030, + 0x1032, 0x1037, 0x1039, 0x103A, 0x103D, 0x103E, 0x1058, 0x1059, + 0x105E, 0x1060, 0x1071, 0x1074, 0x1082, 0x1082, 0x1085, 0x1086, + 0x108D, 0x108D, 0x109D, 0x109D, 0x10FC, 0x10FC, 0x135D, 0x135F, + 0x1712, 0x1714, 0x1732, 0x1733, 0x1752, 0x1753, 0x1772, 0x1773, + 0x17B4, 0x17B5, 0x17B7, 0x17BD, 0x17C6, 0x17C6, 0x17C9, 0x17D3, + 0x17D7, 0x17D7, 0x17DD, 0x17DD, 0x180B, 0x180D, 0x180E, 0x180E, + 0x180F, 0x180F, 0x1843, 0x1843, 0x1885, 0x1886, 0x18A9, 0x18A9, + 0x1920, 0x1922, 0x1927, 0x1928, 0x1932, 0x1932, 0x1939, 0x193B, + 0x1A17, 0x1A18, 0x1A1B, 0x1A1B, 0x1A56, 0x1A56, 0x1A58, 0x1A5E, + 0x1A60, 0x1A60, 0x1A62, 0x1A62, 0x1A65, 0x1A6C, 0x1A73, 0x1A7C, + 0x1A7F, 0x1A7F, 0x1AA7, 0x1AA7, 0x1AB0, 0x1ABD, 0x1ABE, 0x1ABE, + 0x1ABF, 0x1ADD, 0x1AE0, 0x1AEB, 0x1B00, 0x1B03, 0x1B34, 0x1B34, + 0x1B36, 0x1B3A, 0x1B3C, 0x1B3C, 0x1B42, 0x1B42, 0x1B6B, 0x1B73, + 0x1B80, 0x1B81, 0x1BA2, 0x1BA5, 0x1BA8, 0x1BA9, 0x1BAB, 0x1BAD, + 0x1BE6, 0x1BE6, 0x1BE8, 0x1BE9, 0x1BED, 0x1BED, 0x1BEF, 0x1BF1, + 0x1C2C, 0x1C33, 0x1C36, 0x1C37, 0x1C78, 0x1C7D, 0x1CD0, 0x1CD2, + 0x1CD4, 0x1CE0, 0x1CE2, 0x1CE8, 0x1CED, 0x1CED, 0x1CF4, 0x1CF4, + 0x1CF8, 0x1CF9, 0x1D2C, 0x1D6A, 0x1D78, 0x1D78, 0x1D9B, 0x1DBF, + 0x1DC0, 0x1DFF, 0x1FBD, 0x1FBD, 0x1FBF, 0x1FC1, 0x1FCD, 0x1FCF, + 0x1FDD, 0x1FDF, 0x1FED, 0x1FEF, 0x1FFD, 0x1FFE, 0x200B, 0x200F, + 0x2018, 0x2018, 0x2019, 0x2019, 0x2024, 0x2024, 0x2027, 0x2027, + 0x202A, 0x202E, 0x2060, 0x2064, 0x2066, 0x206F, 0x2071, 0x2071, + 0x207F, 0x207F, 0x2090, 0x209C, 0x20D0, 0x20DC, 0x20DD, 0x20E0, + 0x20E1, 0x20E1, 0x20E2, 0x20E4, 0x20E5, 0x20F0, 0x2C7C, 0x2C7D, + 0x2CEF, 0x2CF1, 0x2D6F, 0x2D6F, 0x2D7F, 0x2D7F, 0x2DE0, 0x2DFF, + 0x2E2F, 0x2E2F, 0x3005, 0x3005, 0x302A, 0x302D, 0x3031, 0x3035, + 0x303B, 0x303B, 0x3099, 0x309A, 0x309B, 0x309C, 0x309D, 0x309E, + 0x30FC, 0x30FE, 0xA015, 0xA015, 0xA4F8, 0xA4FD, 0xA60C, 0xA60C, + 0xA66F, 0xA66F, 0xA670, 0xA672, 0xA674, 0xA67D, 0xA67F, 0xA67F, + 0xA69C, 0xA69D, 0xA69E, 0xA69F, 0xA6F0, 0xA6F1, 0xA700, 0xA716, + 0xA717, 0xA71F, 0xA720, 0xA721, 0xA770, 0xA770, 0xA788, 0xA788, + 0xA789, 0xA78A, 0xA7F1, 0xA7F4, 0xA7F8, 0xA7F9, 0xA802, 0xA802, + 0xA806, 0xA806, 0xA80B, 0xA80B, 0xA825, 0xA826, 0xA82C, 0xA82C, + 0xA8C4, 0xA8C5, 0xA8E0, 0xA8F1, 0xA8FF, 0xA8FF, 0xA926, 0xA92D, + 0xA947, 0xA951, 0xA980, 0xA982, 0xA9B3, 0xA9B3, 0xA9B6, 0xA9B9, + 0xA9BC, 0xA9BD, 0xA9CF, 0xA9CF, 0xA9E5, 0xA9E5, 0xA9E6, 0xA9E6, + 0xAA29, 0xAA2E, 0xAA31, 0xAA32, 0xAA35, 0xAA36, 0xAA43, 0xAA43, + 0xAA4C, 0xAA4C, 0xAA70, 0xAA70, 0xAA7C, 0xAA7C, 0xAAB0, 0xAAB0, + 0xAAB2, 0xAAB4, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xAAC1, + 0xAADD, 0xAADD, 0xAAEC, 0xAAED, 0xAAF3, 0xAAF4, 0xAAF6, 0xAAF6, + 0xAB5B, 0xAB5B, 0xAB5C, 0xAB5F, 0xAB69, 0xAB69, 0xAB6A, 0xAB6B, + 0xABE5, 0xABE5, 0xABE8, 0xABE8, 0xABED, 0xABED, 0xFB1E, 0xFB1E, + 0xFBB2, 0xFBC2, 0xFE00, 0xFE0F, 0xFE13, 0xFE13, 0xFE20, 0xFE2F, + 0xFE52, 0xFE52, 0xFE55, 0xFE55, 0xFEFF, 0xFEFF, 0xFF07, 0xFF07, + 0xFF0E, 0xFF0E, 0xFF1A, 0xFF1A, 0xFF3E, 0xFF3E, 0xFF40, 0xFF40, + 0xFF70, 0xFF70, 0xFF9E, 0xFF9F, 0xFFE3, 0xFFE3, 0xFFF9, 0xFFFB, + 0x101FD, 0x101FD, 0x102E0, 0x102E0, 0x10376, 0x1037A, 0x10780, 0x10785, + 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10A01, 0x10A03, 0x10A05, 0x10A06, + 0x10A0C, 0x10A0F, 0x10A38, 0x10A3A, 0x10A3F, 0x10A3F, 0x10AE5, 0x10AE6, + 0x10D24, 0x10D27, 0x10D4E, 0x10D4E, 0x10D69, 0x10D6D, 0x10D6F, 0x10D6F, + 0x10EAB, 0x10EAC, 0x10EC5, 0x10EC5, 0x10EFA, 0x10EFF, 0x10F46, 0x10F50, + 0x10F82, 0x10F85, 0x11001, 0x11001, 0x11038, 0x11046, 0x11070, 0x11070, + 0x11073, 0x11074, 0x1107F, 0x11081, 0x110B3, 0x110B6, 0x110B9, 0x110BA, + 0x110BD, 0x110BD, 0x110C2, 0x110C2, 0x110CD, 0x110CD, 0x11100, 0x11102, + 0x11127, 0x1112B, 0x1112D, 0x11134, 0x11173, 0x11173, 0x11180, 0x11181, + 0x111B6, 0x111BE, 0x111C9, 0x111CC, 0x111CF, 0x111CF, 0x1122F, 0x11231, + 0x11234, 0x11234, 0x11236, 0x11237, 0x1123E, 0x1123E, 0x11241, 0x11241, + 0x112DF, 0x112DF, 0x112E3, 0x112EA, 0x11300, 0x11301, 0x1133B, 0x1133C, + 0x11340, 0x11340, 0x11366, 0x1136C, 0x11370, 0x11374, 0x113BB, 0x113C0, + 0x113CE, 0x113CE, 0x113D0, 0x113D0, 0x113D2, 0x113D2, 0x113E1, 0x113E2, + 0x11438, 0x1143F, 0x11442, 0x11444, 0x11446, 0x11446, 0x1145E, 0x1145E, + 0x114B3, 0x114B8, 0x114BA, 0x114BA, 0x114BF, 0x114C0, 0x114C2, 0x114C3, + 0x115B2, 0x115B5, 0x115BC, 0x115BD, 0x115BF, 0x115C0, 0x115DC, 0x115DD, + 0x11633, 0x1163A, 0x1163D, 0x1163D, 0x1163F, 0x11640, 0x116AB, 0x116AB, + 0x116AD, 0x116AD, 0x116B0, 0x116B5, 0x116B7, 0x116B7, 0x1171D, 0x1171D, + 0x1171F, 0x1171F, 0x11722, 0x11725, 0x11727, 0x1172B, 0x1182F, 0x11837, + 0x11839, 0x1183A, 0x1193B, 0x1193C, 0x1193E, 0x1193E, 0x11943, 0x11943, + 0x119D4, 0x119D7, 0x119DA, 0x119DB, 0x119E0, 0x119E0, 0x11A01, 0x11A0A, + 0x11A33, 0x11A38, 0x11A3B, 0x11A3E, 0x11A47, 0x11A47, 0x11A51, 0x11A56, + 0x11A59, 0x11A5B, 0x11A8A, 0x11A96, 0x11A98, 0x11A99, 0x11B60, 0x11B60, + 0x11B62, 0x11B64, 0x11B66, 0x11B66, 0x11C30, 0x11C36, 0x11C38, 0x11C3D, + 0x11C3F, 0x11C3F, 0x11C92, 0x11CA7, 0x11CAA, 0x11CB0, 0x11CB2, 0x11CB3, + 0x11CB5, 0x11CB6, 0x11D31, 0x11D36, 0x11D3A, 0x11D3A, 0x11D3C, 0x11D3D, + 0x11D3F, 0x11D45, 0x11D47, 0x11D47, 0x11D90, 0x11D91, 0x11D95, 0x11D95, + 0x11D97, 0x11D97, 0x11DD9, 0x11DD9, 0x11EF3, 0x11EF4, 0x11F00, 0x11F01, + 0x11F36, 0x11F3A, 0x11F40, 0x11F40, 0x11F42, 0x11F42, 0x11F5A, 0x11F5A, + 0x13430, 0x1343F, 0x13440, 0x13440, 0x13447, 0x13455, 0x1611E, 0x16129, + 0x1612D, 0x1612F, 0x16AF0, 0x16AF4, 0x16B30, 0x16B36, 0x16B40, 0x16B43, + 0x16D40, 0x16D42, 0x16D6B, 0x16D6C, 0x16F4F, 0x16F4F, 0x16F8F, 0x16F92, + 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x16FE4, 0x16FE4, + 0x16FF2, 0x16FF3, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, + 0x1BC9D, 0x1BC9E, 0x1BCA0, 0x1BCA3, 0x1CF00, 0x1CF2D, 0x1CF30, 0x1CF46, + 0x1D167, 0x1D169, 0x1D173, 0x1D17A, 0x1D17B, 0x1D182, 0x1D185, 0x1D18B, + 0x1D1AA, 0x1D1AD, 0x1D242, 0x1D244, 0x1DA00, 0x1DA36, 0x1DA3B, 0x1DA6C, + 0x1DA75, 0x1DA75, 0x1DA84, 0x1DA84, 0x1DA9B, 0x1DA9F, 0x1DAA1, 0x1DAAF, + 0x1E000, 0x1E006, 0x1E008, 0x1E018, 0x1E01B, 0x1E021, 0x1E023, 0x1E024, + 0x1E026, 0x1E02A, 0x1E030, 0x1E06D, 0x1E08F, 0x1E08F, 0x1E130, 0x1E136, + 0x1E137, 0x1E13D, 0x1E2AE, 0x1E2AE, 0x1E2EC, 0x1E2EF, 0x1E4EB, 0x1E4EB, + 0x1E4EC, 0x1E4EF, 0x1E5EE, 0x1E5EF, 0x1E6E3, 0x1E6E3, 0x1E6E6, 0x1E6E6, + 0x1E6EE, 0x1E6EF, 0x1E6F5, 0x1E6F5, 0x1E6FF, 0x1E6FF, 0x1E8D0, 0x1E8D6, + 0x1E944, 0x1E94A, 0x1E94B, 0x1E94B, 0x1F3FB, 0x1F3FF, 0xE0001, 0xE0001, + 0xE0020, 0xE007F, 0xE0100, 0xE01EF, + ]; + + internal static bool IsCased(int codePoint) => Contains(CasedRanges, codePoint); + + internal static bool IsCaseIgnorable(int codePoint) => Contains(CaseIgnorableRanges, codePoint); + + static bool Contains(int[] ranges, int codePoint) + { + var lower = 0; + var upper = ranges.Length / 2 - 1; + while (lower <= upper) + { + var middle = lower + (upper - lower) / 2; + var start = ranges[middle * 2]; + var end = ranges[middle * 2 + 1]; + if (codePoint < start) + { + upper = middle - 1; + } + else if (codePoint > end) + { + lower = middle + 1; + } + else + { + return true; + } + } + return false; + } +} diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index 11780c5b..e0650db7 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -37,6 +37,31 @@ static LocalEvaluationApiResult CreateFlags(string key, IReadOnlyList EvaluatePropertyFilter( + string filterJson, + object? propertyValue, + ComparisonOperator comparison) + { + var filterValue = PropertyFilterValue.Create(JsonDocument.Parse(filterJson).RootElement); + Assert.NotNull(filterValue); + var flags = CreateFlags( + key: "property", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "property", + Value = filterValue, + Operator = comparison + } + ]); + + return new LocalEvaluator(flags).EvaluateFeatureFlag( + key: "property", + distinctId: "1234", + personProperties: new Dictionary { ["property"] = propertyValue }); + } + [Theory] [InlineData("tyrion@example.com", ComparisonOperator.Exact, true)] [InlineData("TYRION@example.com", ComparisonOperator.Exact, true)] // Case-insensitive @@ -81,6 +106,102 @@ public void HandlesExactMatchWithStringValuesArray(string? email, ComparisonOper Assert.Equal(expected, result); } + [Theory] + [InlineData(ComparisonOperator.Exact, true)] + [InlineData(ComparisonOperator.IsNot, false)] + public void BooleanFilterMatchesPresentNullPropertyLikeTheFlagsService( + ComparisonOperator comparison, + bool expected) + { + var flags = CreateFlags( + key: "nullable", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "nullable", + Value = new PropertyFilterValue(false), + Operator = comparison + } + ] + ); + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "nullable", + distinctId: "1234", + personProperties: new Dictionary { ["nullable"] = null }); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("\"null\"", ComparisonOperator.Exact, true)] + [InlineData("\"null\"", ComparisonOperator.IsNot, false)] + [InlineData("\"NULL\"", ComparisonOperator.ContainsIgnoreCase, true)] + [InlineData("\"NULL\"", ComparisonOperator.DoesNotContainIgnoreCase, false)] + [InlineData("\"NU\"", ComparisonOperator.StartsWith, true)] + [InlineData("\"NU\"", ComparisonOperator.NotStartsWith, false)] + [InlineData("\"LL\"", ComparisonOperator.EndsWith, true)] + [InlineData("\"LL\"", ComparisonOperator.NotEndsWith, false)] + [InlineData("\"^null$\"", ComparisonOperator.Regex, true)] + [InlineData("\"^null$\"", ComparisonOperator.NotRegex, false)] + public void StringOperatorsUseBackendNullStringification( + string filterJson, + ComparisonOperator comparison, + bool expected) + { + Assert.Equal(expected, EvaluatePropertyFilter(filterJson, null, comparison)); + } + + [Theory] + [InlineData("\"ΠΑΡΑΓΓΕΛΙΕΣ\"", "παραγγελιες", ComparisonOperator.Exact, true)] + [InlineData("\"ΠΑΡΑΓΓΕΛΙΕΣ\"", "παραγγελιες", ComparisonOperator.IsNot, false)] + [InlineData("[\"ΠΑΡΑΓΓΕΛΙΕΣ\"]", "παραγγελιες", ComparisonOperator.Exact, true)] + [InlineData("[\"ΠΑΡΑΓΓΕΛΙΕΣ\"]", "παραγγελιες", ComparisonOperator.IsNot, false)] + [InlineData("\"ΠΑΡΑΓΓΕΛΙΕΣ\"", "παραγγελιεσ", ComparisonOperator.Exact, false)] + [InlineData("\"ΠΑΡΑΓΓΕΛΙΕΣ\"", "παραγγελιεσ", ComparisonOperator.IsNot, true)] + [InlineData("\"İ\"", "i\u0307", ComparisonOperator.Exact, true)] + [InlineData("[\"İ\"]", "i\u0307", ComparisonOperator.IsNot, false)] + public void ExactAndIsNotUseBackendUnicodeLowercaseForScalarAndListFilters( + string filterJson, + string propertyValue, + ComparisonOperator comparison, + bool expected) + { + Assert.Equal(expected, EvaluatePropertyFilter(filterJson, propertyValue, comparison)); + } + + [Theory] + [InlineData("[]", true, ComparisonOperator.Exact, true)] + [InlineData("[]", true, ComparisonOperator.IsNot, false)] + [InlineData("[\"true\",\"false\"]", "true", ComparisonOperator.Exact, false)] + [InlineData("[\"true\",\"false\"]", "true", ComparisonOperator.IsNot, true)] + [InlineData("[\"true\",\"false\"]", "pro", ComparisonOperator.Exact, true)] + [InlineData("[\"true\",\"false\"]", "pro", ComparisonOperator.IsNot, false)] + [InlineData("[\"FREE\",\"PRO\"]", "pro", ComparisonOperator.Exact, true)] + [InlineData("[\"FREE\",\"PRO\"]", "pro", ComparisonOperator.IsNot, false)] + public void ExactAndIsNotComplementBackendBooleanArrayPrecedence( + string filterJson, + object propertyValue, + ComparisonOperator comparison, + bool expected) + { + Assert.Equal(expected, EvaluatePropertyFilter(filterJson, propertyValue, comparison)); + } + + [Theory] + [InlineData(ComparisonOperator.Exact, true)] + [InlineData(ComparisonOperator.IsNot, false)] + public void ExactAndIsNotComplementCanonicalJsonMatching(ComparisonOperator comparison, bool expected) + { + var propertyValue = new Dictionary { ["b"] = 1, ["a"] = new object[] { 2, 3 } }; + + Assert.Equal( + expected, + EvaluatePropertyFilter("\"{\\\"a\\\":[2,3],\\\"b\\\":1}\"", propertyValue, comparison)); + } + [Theory] [InlineData("internal/1234", ComparisonOperator.Exact, true)] [InlineData("INTERNAL/1234", ComparisonOperator.Exact, true)] // Case-insensitive @@ -372,6 +493,8 @@ public void MatchesRegexUserProperty(object overrideValue, ComparisonOperator co [InlineData("Works at PostHog", ComparisonOperator.ContainsIgnoreCase, "\"posthog\"", true)] [InlineData("Works at PostHog", ComparisonOperator.DoesNotContainIgnoreCase, "\"posthog\"", false)] [InlineData("Works at PostHog", ComparisonOperator.DoesNotContainIgnoreCase, "\"PostHog\"", false)] + [InlineData("Äbc", ComparisonOperator.ContainsIgnoreCase, "\"ä\"", false)] + [InlineData("Äbc", ComparisonOperator.DoesNotContainIgnoreCase, "\"ä\"", true)] [InlineData("Loves puppies", ComparisonOperator.ContainsIgnoreCase, "\"cats\"", false)] [InlineData("Loves puppies", ComparisonOperator.DoesNotContainIgnoreCase, "\"cats\"", true)] public void HandlesContainsComparisons(object overrideValue, ComparisonOperator comparison, string filterValueJson, bool expected) @@ -409,7 +532,10 @@ public void HandlesContainsComparisons(object overrideValue, ComparisonOperator [InlineData("vaLue4", ComparisonOperator.StartsWith, "\"Val\"", true)] [InlineData("prevalue", ComparisonOperator.StartsWith, "\"Val\"", false)] [InlineData("Alakazam", ComparisonOperator.StartsWith, "\"Val\"", false)] + [InlineData("Äbc", ComparisonOperator.StartsWith, "\"ä\"", false)] + [InlineData("Äbc", ComparisonOperator.NotStartsWith, "\"ä\"", true)] [InlineData(323, ComparisonOperator.StartsWith, "\"3\"", true)] + [InlineData(323.0, ComparisonOperator.StartsWith, "\"323.\"", true)] [InlineData(123, ComparisonOperator.StartsWith, "\"3\"", false)] [InlineData("value", ComparisonOperator.NotStartsWith, "\"Val\"", false)] [InlineData("VALUE", ComparisonOperator.NotStartsWith, "\"Val\"", false)] @@ -420,7 +546,10 @@ public void HandlesContainsComparisons(object overrideValue, ComparisonOperator [InlineData("343tfvalue", ComparisonOperator.EndsWith, "\"lUe\"", true)] [InlineData("value2", ComparisonOperator.EndsWith, "\"lUe\"", false)] [InlineData("Alakazam", ComparisonOperator.EndsWith, "\"lUe\"", false)] + [InlineData("bcÄ", ComparisonOperator.EndsWith, "\"ä\"", false)] + [InlineData("bcÄ", ComparisonOperator.NotEndsWith, "\"ä\"", true)] [InlineData(323, ComparisonOperator.EndsWith, "\"3\"", true)] + [InlineData(323.0, ComparisonOperator.EndsWith, "\"3\"", false)] [InlineData(13, ComparisonOperator.EndsWith, "\"3\"", true)] [InlineData(321, ComparisonOperator.EndsWith, "\"3\"", false)] [InlineData("value", ComparisonOperator.NotEndsWith, "\"lUe\"", false)] @@ -457,11 +586,13 @@ public void HandlesStartsWithAndEndsWithComparisons(object overrideValue, Compar } [Theory] - [InlineData(ComparisonOperator.StartsWith)] - [InlineData(ComparisonOperator.NotStartsWith)] - [InlineData(ComparisonOperator.EndsWith)] - [InlineData(ComparisonOperator.NotEndsWith)] - public void ReturnsFalseWhenPropertyValueIsNullForStartsWithAndEndsWithComparisons(ComparisonOperator comparison) + [InlineData(ComparisonOperator.StartsWith, false)] + [InlineData(ComparisonOperator.NotStartsWith, true)] + [InlineData(ComparisonOperator.EndsWith, false)] + [InlineData(ComparisonOperator.NotEndsWith, true)] + public void StringifiesNullForStartsWithAndEndsWithComparisons( + ComparisonOperator comparison, + bool expected) { var flags = CreateFlags( key: "bio", @@ -487,8 +618,7 @@ public void ReturnsFalseWhenPropertyValueIsNullForStartsWithAndEndsWithCompariso distinctId: "distinct-id", personProperties: properties); - // A null property value fails the comparison for both the positive and not_ variants. - Assert.False(result.Value); + Assert.Equal(expected, result.Value); } [Theory] diff --git a/tests/UnitTests/Fixtures/Snapshots/local-evaluation-definitions-projection.json b/tests/UnitTests/Fixtures/Snapshots/local-evaluation-definitions-projection.json index 3eb2ac9c..fa9461d6 100644 --- a/tests/UnitTests/Fixtures/Snapshots/local-evaluation-definitions-projection.json +++ b/tests/UnitTests/Fixtures/Snapshots/local-evaluation-definitions-projection.json @@ -40,7 +40,7 @@ { "type": "person", "key": "numeric-array", - "value": ["1.00"], + "value": ["1.0"], "operator": "exact", "group_type_index": null, "negation": false, @@ -54,7 +54,7 @@ { "input_type": "string", "input": "1.00", - "matches": true + "matches": false } ] }, diff --git a/tests/UnitTests/Json/PropertyFilterValueTests.cs b/tests/UnitTests/Json/PropertyFilterValueTests.cs index a16b316e..69fd8e0b 100644 --- a/tests/UnitTests/Json/PropertyFilterValueTests.cs +++ b/tests/UnitTests/Json/PropertyFilterValueTests.cs @@ -10,6 +10,23 @@ public class TheIsExactMatchMethod [InlineData("scooby", "\"scooby\"", true)] [InlineData("SCOOBY", "\"scooby\"", true)] [InlineData("ScOoBy", "\"sCoObY\"", true)] + [InlineData("ä", "\"Ä\"", true)] + [InlineData("i\u0307", "\"\\u0130\"", true)] + [InlineData("ς", "\"Σ\"", false)] + [InlineData("ος", "\"ΟΣ\"", true)] + [InlineData("οσ", "\"ΟΣ\"", false)] + [InlineData("οδος", "\"ΟΔΟΣ\"", true)] + [InlineData("οδοσ", "\"ΟΔΟΣ\"", false)] + [InlineData("παραγγελιες", "\"ΠΑΡΑΓΓΕΛΙΕΣ\"", true)] + [InlineData("παραγγελιεσ", "\"ΠΑΡΑΓΓΕΛΙΕΣ\"", false)] + [InlineData("παραγγελιες", "[\"ΠΑΡΑΓΓΕΛΙΕΣ\"]", true)] + [InlineData("παραγγελιεσ", "[\"ΠΑΡΑΓΓΕΛΙΕΣ\"]", false)] + [InlineData("a\u0301ς", "\"A\\u0301Σ\"", true)] + [InlineData("aς\u0301", "\"AΣ\\u0301\"", true)] + [InlineData("aσ\u0301b", "\"AΣ\\u0301B\"", true)] + [InlineData("a.ς", "\"A.Σ\"", true)] + [InlineData("a σ", "\"A Σ\"", true)] + [InlineData("ss", "\"ß\"", false)] [InlineData("", "\"shaggy\"", false)] [InlineData(null, "\"shaggy\"", false)] [InlineData("scooby", "\"shaggy\"", false)] @@ -51,6 +68,105 @@ public void ReturnsTrueWhenPropertyValueMatchesString(object? overrideValue, str Assert.Equal(expected, filterPropertyValue.IsExactMatch(overrideValue)); } + [Fact] + public void MatchesBackendBooleanArrayPrecedence() + { + var cases = new (string FilterJson, object? OverrideValue, bool Expected)[] + { + ("false", "banana", true), + ("\"false\"", 0, true), + ("[\"false\"]", null, true), + ("[\"true\",\"false\"]", "true", false), + ("[\"true\",\"false\"]", "pro", true), + ("[]", true, true), + ("[]", "true", true), + ("[]", Array.Empty(), true), + ("[]", new object[] { true }, true), + ("[]", false, false), + ("[]", "banana", false), + ("[\"FREE\",\"PRO\"]", "pro", true), + ("\"falſe\"", 0, false) + }; + + foreach (var (filterJson, overrideValue, expected) in cases) + { + var filterPropertyValue = PropertyFilterValue.Create(JsonDocument.Parse(filterJson).RootElement); + + Assert.NotNull(filterPropertyValue); + Assert.Equal(expected, filterPropertyValue.IsExactMatch(overrideValue)); + } + } + + [Fact] + public void StringifiesJsonValuesLikeTheFlagsService() + { + var cases = new (string FilterJson, object OverrideValue, bool Expected)[] + { + ("\"[1,2]\"", new object[] { 1, 2 }, true), + ("\"{\\\"a\\\":2,\\\"b\\\":1}\"", new Dictionary { ["b"] = 1, ["a"] = 2 }, true), + ("\"{\\\"a\\\":{\\\"c\\\":3,\\\"d\\\":4},\\\"z\\\":0}\"", new Dictionary { ["z"] = 0, ["a"] = new Dictionary { ["d"] = 4, ["c"] = 3 } }, true), + ("\"{\\\"\\\":1,\\\"𐀀\\\":2}\"", new Dictionary { ["𐀀"] = 2, [""] = 1 }, true), + ("\"{\\\"a\\\":2,\\\"b\\\":1}\"", JsonDocument.Parse("{\"b\":1,\"a\":2}").RootElement, true), + ("\"1.0\"", JsonDocument.Parse("1.0").RootElement, true), + ("\"1e-7\"", 1e-7, true), + ("\"1000000000000000.0\"", 1e15, true), + ("\"1e+16\"", 1e16, true), + ("\"0.00001\"", 1e-5, true), + ("\"0.000099\"", 9.9e-5, true), + ("\"-0.0\"", -0.0, true) + }; + + foreach (var (filterJson, overrideValue, expected) in cases) + { + var filterPropertyValue = PropertyFilterValue.Create(JsonDocument.Parse(filterJson).RootElement); + + Assert.NotNull(filterPropertyValue); + Assert.Equal(expected, filterPropertyValue.IsExactMatch(overrideValue)); + } + } + + [Fact] + public void QuotesNestedJsonStringElements() + { + using var stringDocument = JsonDocument.Parse("\"x\""); + var overrideValue = new Dictionary + { + ["document"] = stringDocument, + ["element"] = stringDocument.RootElement + }; + var filterPropertyValue = PropertyFilterValue.Create( + JsonDocument.Parse("\"{\\\"document\\\":\\\"x\\\",\\\"element\\\":\\\"x\\\"}\"").RootElement); + + Assert.NotNull(filterPropertyValue); + Assert.True(filterPropertyValue.IsExactMatch(overrideValue)); + } + + [Fact] + public void UnrepresentableRecursiveJsonValuesDoNotCrashMatching() + { + var cyclicValue = new Dictionary(); + cyclicValue["self"] = cyclicValue; + object deeplyNestedValue = "leaf"; + for (var depth = 0; depth < 65; depth++) + { + deeplyNestedValue = new object[] { deeplyNestedValue }; + } + var recursiveArray = new object[1]; + recursiveArray[0] = recursiveArray; + var filterPropertyValue = PropertyFilterValue.Create(JsonDocument.Parse("\"never\"").RootElement); + var emptyObjectFilter = PropertyFilterValue.Create(JsonDocument.Parse("\"{}\"").RootElement); + var falseFilter = PropertyFilterValue.Create(JsonDocument.Parse("false").RootElement); + var nonStringKeyValue = new System.Collections.Hashtable { [1] = "value" }; + + Assert.NotNull(filterPropertyValue); + Assert.NotNull(emptyObjectFilter); + Assert.NotNull(falseFilter); + Assert.False(filterPropertyValue.IsExactMatch(cyclicValue)); + Assert.False(filterPropertyValue.IsExactMatch(deeplyNestedValue)); + Assert.False(emptyObjectFilter.IsExactMatch(nonStringKeyValue)); + Assert.True(falseFilter.IsExactMatch(recursiveArray)); + } + [Fact] public void NumericArrayMatchesDecimalRegardlessOfScale() { @@ -71,6 +187,10 @@ public void NumericArrayDoesNotThrowForLargeSingleOverride() [Theory] [InlineData(3.14, "\"3.14\"", true)] + [InlineData(323.0, "\"323.0\"", true)] + [InlineData(323.0, "\"323\"", false)] + [InlineData(323, "\"323\"", true)] + [InlineData(323, "\"323.0\"", false)] [InlineData(3.14, "\"3,14\"", false)] [InlineData(1.618, "\"3.14\"", false)] [InlineData(3.14, """["1", "3.14", "42"]""", true)]