diff --git a/.changeset/versioned-property-matching.md b/.changeset/versioned-property-matching.md new file mode 100644 index 00000000..2d3e1d49 --- /dev/null +++ b/.changeset/versioned-property-matching.md @@ -0,0 +1,5 @@ +--- +"PostHog": patch +--- + +Honor definition snapshot `property_matching_version` during local feature flag evaluation. Version 2 uses normalized scalar and list-member equality instead of aggregate boolean coercion, including known null properties; missing and other versions retain legacy matching. Empty filter lists keep recursive truthiness in both modes. Matching semantics remain tied to cached definitions across refreshes and 304 responses. diff --git a/src/PostHog/Api/LocalEvaluationApiResult.cs b/src/PostHog/Api/LocalEvaluationApiResult.cs index 1558099b..124aa854 100644 --- a/src/PostHog/Api/LocalEvaluationApiResult.cs +++ b/src/PostHog/Api/LocalEvaluationApiResult.cs @@ -18,6 +18,13 @@ internal record LocalEvaluationApiResult /// public required IReadOnlyList Flags { get; init; } + /// + /// Property matching semantics for this definition snapshot. Only version 2 enables explicit matching; + /// missing and other versions retain legacy matching. + /// + [JsonPropertyName("property_matching_version")] + public int? PropertyMatchingVersion { get; init; } + /// /// Mappings of group IDs to group type. /// @@ -59,14 +66,15 @@ public virtual bool Equals(LocalEvaluationApiResult? other) return Flags.ListsAreEqual(other.Flags) && GroupTypeMapping.DictionariesAreEqual(other.GroupTypeMapping) && Cohorts.DictionariesAreEqual(other.Cohorts) - && MinimalFlagCalledEvents == other.MinimalFlagCalledEvents; + && MinimalFlagCalledEvents == other.MinimalFlagCalledEvents + && PropertyMatchingVersion == other.PropertyMatchingVersion; } /// /// Serves as the default hash function. /// /// A hash code for the current object. - public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents); + public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents, PropertyMatchingVersion); } /// diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index 446c359d..946e80e1 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -678,8 +678,8 @@ or ComparisonOperator.Regex return propertyFilter.Operator switch { - ComparisonOperator.Exact => value.IsExactMatch(overrideValue), - ComparisonOperator.IsNot => !value.IsExactMatch(overrideValue), + ComparisonOperator.Exact => value.IsExactMatch(overrideValue, LocalEvaluationApiResult.PropertyMatchingVersion), + ComparisonOperator.IsNot => !value.IsExactMatch(overrideValue, LocalEvaluationApiResult.PropertyMatchingVersion), ComparisonOperator.GreaterThan => value < overrideValue, ComparisonOperator.GreaterThanOrEquals => value <= overrideValue, ComparisonOperator.LessThan => value > overrideValue, diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs index 6f2db5f0..a3b4feaa 100644 --- a/src/PostHog/Json/PropertyFilterValue.cs +++ b/src/PostHog/Json/PropertyFilterValue.cs @@ -190,8 +190,31 @@ internal bool IsSuffixOfAsciiIgnoreCase(object? other) => /// /// The override value. /// true if the override value is an "exact" match for this value. - public bool IsExactMatch(object? overrideValue) + public bool IsExactMatch(object? overrideValue) => IsExactMatch(overrideValue, propertyMatchingVersion: null); + + internal bool IsExactMatch(object? overrideValue, int? propertyMatchingVersion) { + if (propertyMatchingVersion == 2) + { + // Empty filters retain recursive legacy truthiness, not empty ANY membership. + if (ListOfStrings is { Count: 0 }) + { + return IsTruthyPropertyValue(overrideValue); + } + + var comparand = overrideValue is decimal decimalValue + ? StringifyDecimal(decimalValue) + : ToInvariantString(overrideValue); + return this switch + { + { ListOfStrings: { } values } => values.Any(value => UnicodeLowercaseEquals(value, comparand)), + { StringValue: { } value } => UnicodeLowercaseEquals(value, comparand), + { BooleanValue: { } value } => UnicodeLowercaseEquals(value ? "true" : "false", comparand), + { CohortId: { } value } => UnicodeLowercaseEquals(value.ToString(CultureInfo.InvariantCulture), comparand), + _ => false + }; + } + if (TryGetBooleanValue(out var booleanValue)) { return booleanValue == IsTruthyPropertyValue(overrideValue); @@ -205,6 +228,13 @@ public bool IsExactMatch(object? overrideValue) }; } + static string StringifyDecimal(decimal value) + { + // Preserve the wire number's integer/float distinction and normalize its scale and precision like JSON filters. + using var document = JsonDocument.Parse(JsonSerializer.Serialize(value)); + return StringifyJsonElement(document.RootElement); + } + bool TryGetBooleanValue(out bool value) { if (BooleanValue is { } booleanValue) diff --git a/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs b/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs index 516422f3..3184cadb 100644 --- a/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs +++ b/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs @@ -1,12 +1,94 @@ using System.Net; using PostHog; +using PostHog.Api; +using PostHog.Features; +using PostHog.Json; using UnitTests.Fakes; +using static LocalEvaluatorTests.VersionedPropertyMatchingTests; #if NETCOREAPP3_1 using TestLibrary.Fakes.Polyfills; #endif namespace LocalFeatureFlagsLoaderTests; +public class VersionedDefinitionSnapshots +{ + [Fact] + public async Task PreservesCachedSnapshotOn304AndFailureAndResetsOnVersionOnlyRefresh() + { + var container = new TestContainer("fake-personal-api-key"); + using var httpClient = new HttpClient(container.FakeHttpMessageHandler); + using var apiClient = container.Activate(httpClient); + await using var loader = container.Activate(apiClient); + var handler = container.FakeHttpMessageHandler; + handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", 1), "\"legacy\""); + var legacy = await loader.GetFeatureFlagsForLocalEvaluationAsync(CancellationToken.None); + Assert.NotNull(legacy); + Assert.Equal(true, Evaluate(legacy)); + + handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", 2), "\"explicit\""); + var explicitEvaluator = await loader.RefreshAsync(CancellationToken.None); + Assert.NotNull(explicitEvaluator); + Assert.NotSame(legacy, explicitEvaluator); + Assert.Equal(2, explicitEvaluator.LocalEvaluationApiResult.PropertyMatchingVersion); + Assert.Equal(false, Evaluate(explicitEvaluator)); + Assert.Same(explicitEvaluator, await loader.GetFeatureFlagsForLocalEvaluationAsync(CancellationToken.None)); + Assert.Equal(true, Evaluate(legacy)); // An in-flight reader keeps its original semantics. + + var notModified = handler.AddLocalEvaluationNotModifiedResponse(); + Assert.Same(explicitEvaluator, await loader.RefreshAsync(CancellationToken.None)); + Assert.Equal("\"explicit\"", notModified.ReceivedRequest!.Headers.IfNoneMatch.Single().Tag); + Assert.Equal(false, Evaluate(explicitEvaluator)); + + handler.AddResponse(FakeHttpMessageHandlerExtensions.LocalEvaluationUrl, HttpMethod.Get, new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"type\":\"server_error\",\"detail\":\"unavailable\"}", System.Text.Encoding.UTF8, "application/json") + }); + Assert.Same(explicitEvaluator, await loader.RefreshAsync(CancellationToken.None)); + Assert.Equal(false, Evaluate(explicitEvaluator)); + + foreach (var version in new int?[] { 1, 2, null }) + { + handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", version), "\"refresh\""); + var refreshed = await loader.RefreshAsync(CancellationToken.None); + Assert.NotNull(refreshed); + Assert.Equal(version, refreshed.LocalEvaluationApiResult.PropertyMatchingVersion); + Assert.Equal(version != 2, Evaluate(refreshed)); + } + Assert.Equal(false, Evaluate(explicitEvaluator)); + } + + [Fact] + public async Task PublicSingleBulkAndFullResultsFollowVersionOnlyReloadWithoutRemoteFallback() + { + var container = new TestContainer("fake-personal-api-key"); + await using var client = container.Activate(); + var remote = container.FakeHttpMessageHandler.AddFlagsResponse("""{"flags": {}}"""); + var properties = new Dictionary { ["value"] = "banana" }; + var options = new FeatureFlagOptions { PersonProperties = properties, OnlyEvaluateLocally = true }; + var allOptions = new AllFeatureFlagsOptions { PersonProperties = properties, OnlyEvaluateLocally = true }; + foreach (var version in new int?[] { 1, 2, 1, 2, null }) + { + container.FakeHttpMessageHandler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", version), "\"reload\""); + await client.LoadFeatureFlagsAsync(CancellationToken.None); +#pragma warning disable CS0618 // Verify compatibility of the legacy single-flag API too. + var single = await client.GetFeatureFlagAsync("test", "person", options, CancellationToken.None); +#pragma warning restore CS0618 + Assert.NotNull(single); + Assert.Equal(version != 2, single.IsEnabled); + var bulk = await client.GetAllFeatureFlagsAsync("person", allOptions, CancellationToken.None); + Assert.Equal(version != 2, bulk["test"].IsEnabled); + var full = await client.EvaluateFlagsAsync("person", options, CancellationToken.None); + Assert.Contains("test", full.Keys); + Assert.Equal(version != 2, full.IsEnabled("test")); + } + Assert.Empty(remote.ReceivedRequests); + } + + static StringOrValue Evaluate(LocalEvaluator evaluator) => evaluator.EvaluateFeatureFlag( + "test", "person", personProperties: new() { ["value"] = "banana" }); +} + public class TheDisposeAsyncMethod { const string LocalEvaluationResponse = """ diff --git a/tests/UnitTests/Features/VersionedPropertyMatchingTests.cs b/tests/UnitTests/Features/VersionedPropertyMatchingTests.cs new file mode 100644 index 00000000..9c921e28 --- /dev/null +++ b/tests/UnitTests/Features/VersionedPropertyMatchingTests.cs @@ -0,0 +1,243 @@ +using System.Text.Json; +using PostHog; +using PostHog.Api; +using PostHog.Exceptions; +using PostHog.Features; +using PostHog.Json; + +namespace LocalEvaluatorTests; + +public class VersionedPropertyMatchingTests +{ + public static TheoryData MatchingCases => new() + { + { "false", "\"banana\"", true, false }, + { "false", "0", true, false }, + { "[\"true\",\"false\"]", "\"true\"", false, true }, + { "[\"true\",\"false\"]", "\"pro\"", true, false }, + { "[]", "true", true, true }, + { "[]", "[]", true, true }, + { "true", "[true]", true, false }, + { "false", "\"FALSE\"", true, true }, + { "false", "null", true, false }, + { "false", "\"\"", true, false }, + { "[]", "[true,[\"TRUE\",[]]]", true, true }, + { "[]", "[true,[false]]", false, false }, + { "[]", "false", false, false }, + { "[]", "null", false, false }, + { "[]", "0", false, false }, + { "[]", "1", false, false }, + { "[]", "\"banana\"", false, false }, + { "[\"FREE\",\"PRO\"]", "\"pro\"", true, true }, + { "[false,\"PRO\"]", "\"pro\"", true, true }, + { "[[true],\"PRO\"]", "[true]", true, true }, + { "[\"TrUe\",\"FALSE\"]", "true", false, true }, + { "[\"TrUe\",\"FALSE\"]", "false", true, true }, + { "[\"İ\",false]", "\"i̇\"", true, true }, + { "\"ΟΔΟΣ\"", "\"οδος\"", true, true }, + { "true", "true", true, true }, + { "false", "false", true, true }, + { "[[true],[false]]", "[true]", false, true } + }; + + [Theory] + [MemberData(nameof(MatchingCases))] + public void SelectsSnapshotSemanticsForExactAndIsNot(string filterJson, string propertyJson, bool legacy, bool explicitMatch) + { + using var property = JsonDocument.Parse(propertyJson); + object? value = property.RootElement.ValueKind == JsonValueKind.Null ? null : property.RootElement; + foreach (var version in new int?[] { null, 1, 2, 0, 3 }) + { + foreach (var comparison in new[] { "exact", "is_not" }) + { + var evaluator = new LocalEvaluator(ParseDefinitions(filterJson, comparison, version)); + var expected = version == 2 ? explicitMatch : legacy; + if (comparison == "is_not") + { + expected = !expected; + } + Assert.Equal(expected, evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = value })); + } + } + } + + [Theory] + [InlineData("[null,\"x\"]", "null")] + [InlineData("[\"NULL\"]", "null")] + public void ExplicitListMembershipIncludesKnownNull(string filterJson, string propertyJson) + { + using var property = JsonDocument.Parse(propertyJson); + var evaluator = new LocalEvaluator(ParseDefinitions(filterJson, "exact", 2)); + Assert.Equal(true, evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = null })); + Assert.Equal(true, evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = property.RootElement })); + } + + [Theory] + [InlineData(1, "exact")] + [InlineData(2, "exact")] + [InlineData(1, "is_not")] + [InlineData(2, "is_not")] + public void MissingPropertyAndUnsupportedNullFilterRemainInconclusive(int version, string comparison) + { + var evaluator = new LocalEvaluator(ParseDefinitions("false", comparison, version)); + Assert.Throws(() => evaluator.EvaluateFeatureFlag("test", "person", personProperties: new())); + evaluator = new LocalEvaluator(ParseDefinitions("null", comparison, version)); + Assert.Throws(() => evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = null })); + } + + [Theory] + [InlineData(null, true)] + [InlineData(1, true)] + [InlineData(2, false)] + public void PersonGroupRecursiveCohortAndDependencyShareSnapshot(int? version, bool expected) + { + var definitions = ParseDefinitions("false", "exact", version); + var person = definitions.Flags[0]; + var leaf = person.Filters!.Groups![0].Properties![0]; + var group = person with + { + Key = "group", + Filters = person.Filters with + { + AggregationGroupTypeIndex = 0, + Groups = [new FeatureFlagGroup { Properties = [leaf with { Type = FilterType.Group }] }] + } + }; + var cohort = person with + { + Key = "cohort", + Filters = new FeatureFlagFilters + { + Groups = [new FeatureFlagGroup + { + Properties = [new PropertyFilter { Key = "id", Type = FilterType.Cohort, Value = new PropertyFilterValue(123L) }] + }] + } + }; + var dependent = person with + { + Key = "dependent", + Filters = new FeatureFlagFilters + { + Groups = [new FeatureFlagGroup + { + Properties = [new PropertyFilter + { + Key = "test", Type = FilterType.Flag, Operator = ComparisonOperator.FlagEvaluatesTo, + Value = new PropertyFilterValue(true), DependencyChain = ["test"] + }] + }] + } + }; + definitions = definitions with + { + Flags = [person, group, cohort, dependent], + GroupTypeMapping = new Dictionary { ["0"] = "company" }, + Cohorts = new Dictionary + { + ["123"] = new FilterSet + { + Type = FilterType.And, + Values = [new FilterSet { Type = FilterType.And, Values = [leaf] }] + } + } + }; + var evaluator = new LocalEvaluator(definitions); + var properties = new Dictionary { ["value"] = "banana" }; + var groups = new GroupCollection { new Group("company", "acme", properties) }; + foreach (var flag in definitions.Flags) + { + Assert.Equal(expected, evaluator.EvaluateFeatureFlag(flag.Key, "person", groups, properties)); + } + var (all, fallback) = evaluator.EvaluateAllFlags("person", groups, properties); + Assert.False(fallback); + Assert.Equal(4, all.Count); + Assert.All(all.Values, flag => Assert.Equal(expected, flag.IsEnabled)); + } + + [Theory] + [InlineData(null)] + [InlineData(1)] + [InlineData(2)] + public void DefinitionsMetadataSurvivesSerialization(int? version) + { + var definitions = ParseDefinitions("false", "exact", version) with { Flags = [] }; + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(definitions, JsonSerializerHelper.Options), JsonSerializerHelper.Options)!; + Assert.Equal(version, restored.PropertyMatchingVersion); + Assert.Equal(definitions, restored); + Assert.NotEqual(definitions, definitions with { PropertyMatchingVersion = version == 2 ? 1 : 2 }); + } + + [Fact] + public void ExplicitMatchingUsesCanonicalNumericRepresentationWithoutLegacyNumericListCoercion() + { + using var document = JsonDocument.Parse("[1.0]"); + var filter = PropertyFilterValue.Create(document.RootElement)!; + Assert.True(filter.IsExactMatch(1)); // Existing legacy numeric-list fallback remains intact. + Assert.False(filter.IsExactMatch(1, 2)); + Assert.True(filter.IsExactMatch(1.0, 2)); + Assert.True(new PropertyFilterValue(1L).IsExactMatch(1, 2)); + Assert.False(new PropertyFilterValue(false).IsExactMatch(0.0, 2)); + } + + public static TheoryData DecimalMatchingCases => new() + { + { 1.00m, "[1.00]", true }, + { 1.00m, "[\"1.0\"]", true }, + { 1.00m, "\"1.0\"", true }, + { 1.00m, "[\"1.00\"]", false }, + { 1.00m, "[1]", false }, + { 1m, "[1]", true }, + { 1m, "[1.0]", false }, + { 1.2300m, "[1.23]", true }, + { 0.00m, "[0.0]", true }, + { -1.00m, "[-1.0]", true }, + { 0.00000100m, "[1e-6]", true }, + { 1.2345678901234567890123456789m, "[1.2345678901234567890123456789]", true }, + { 18446744073709551615m, "[18446744073709551615]", true }, + { decimal.MaxValue, "[79228162514264337593543950335]", true } + }; + + [Theory] + [MemberData(nameof(DecimalMatchingCases))] + public void ExplicitDecimalMatchingUsesWireRepresentation(decimal property, string filterJson, bool exact) + { + using var wire = JsonDocument.Parse(JsonSerializer.Serialize(property)); + foreach (var comparison in new[] { "exact", "is_not" }) + { + var evaluator = new LocalEvaluator(ParseDefinitions(filterJson, comparison, 2)); + var expected = comparison == "exact" ? exact : !exact; + Assert.Equal(expected, evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = wire.RootElement })); + Assert.Equal(expected, evaluator.EvaluateFeatureFlag("test", "person", personProperties: new() { ["value"] = property })); + } + } + + [Fact] + public void DecimalNormalizationPreservesLegacyAndOtherOperators() + { + using var document = JsonDocument.Parse("[1.00]"); + var filter = PropertyFilterValue.Create(document.RootElement)!; + Assert.True(filter.IsExactMatch(1.00m)); + foreach (var version in new int?[] { null, 1, 0, 3 }) + { + Assert.True(filter.IsExactMatch(1.00m, version)); + Assert.True(new PropertyFilterValue("1.00").IsExactMatch(1.00m, version)); + } + Assert.True(new PropertyFilterValue("1.00").IsContainedBy(1.00m, StringComparison.Ordinal)); + } + + internal static LocalEvaluationApiResult ParseDefinitions(string filterJson, string comparison, int? version) => + JsonSerializer.Deserialize(DefinitionsJson(filterJson, comparison, version), JsonSerializerHelper.Options)!; + + internal static string DefinitionsJson(string filterJson, string comparison, int? version) => $$""" + { + {{(version.HasValue ? $"\"property_matching_version\": {version.Value}," : "")}} + "flags": [{ + "key": "test", "active": true, "version": 2, + "filters": {"groups": [{"properties": [ + {"key": "value", "type": "person", "operator": "{{comparison}}", "value": {{filterJson}}} + ]}]} + }] + } + """; +}