Skip to content

Commit 61b26a3

Browse files
authored
feat: support starts_with and ends_with operators in local evaluation (#268)
* feat: support starts_with and ends_with operators in local evaluation Adds local evaluation for the starts_with, not_starts_with, ends_with, and not_ends_with property filter operators (PostHog/posthog#72992). Matching is case-insensitive (OrdinalIgnoreCase) and mirrors icontains: the override value is stringified and compared against the filter's string value, and the Not variants are exact negations. New enum members are appended so existing ComparisonOperator values keep their numeric identity. Generated-By: PostHog Code Task-Id: ef980bb5-ff81-4191-a7df-796e932b8251 * Cover missing-key and null values for starts_with/ends_with operators Generated-By: PostHog Code Task-Id: bb15888a-04c4-4dc0-916e-793a0540ece3 * Map unrecognized operators to Unknown and test operator wire names Deserializing a property filter whose operator this SDK version doesn't recognize maps it to ComparisonOperator.Unknown instead of throwing, so a future server-side operator makes only the affected flag inconclusive (falling back to remote evaluation) rather than rejecting the entire local evaluation response. Also adds focused deserialization tests for the starts_with, not_starts_with, ends_with, and not_ends_with wire names. Generated-By: PostHog Code Task-Id: bb15888a-04c4-4dc0-916e-793a0540ece3
1 parent c4f1b4b commit 61b26a3

10 files changed

Lines changed: 335 additions & 7 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"PostHog": minor
3+
---
4+
5+
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`, so flags using these operators no longer fail local evaluation.
6+
7+
Property filter operators this SDK version doesn't recognize now deserialize as `ComparisonOperator.Unknown` instead of failing the entire local evaluation response, so only the affected flag falls back to remote evaluation.

src/PostHog/Api/ComparisonOperator.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace PostHog.Api;
66
/// <summary>
77
/// An enumeration representing the comparison types that can be used in a filter.
88
/// </summary>
9-
[JsonConverter(typeof(JsonStringEnumMemberNameJsonConverter<ComparisonOperator>))]
9+
[JsonConverter(typeof(ComparisonOperatorJsonConverter))]
1010
public enum ComparisonOperator
1111
{
1212
/// <summary>
@@ -157,5 +157,35 @@ public enum ComparisonOperator
157157
/// Matches if the version matches the wildcard pattern (e.g., "1.2.*" means >=1.2.0 and &lt;1.3.0).
158158
/// </summary>
159159
[JsonStringEnumMemberName("semver_wildcard")]
160-
SemverWildcard
160+
SemverWildcard,
161+
162+
/// <summary>
163+
/// Matches if the value starts with the filter value, ignoring case differences.
164+
/// </summary>
165+
[JsonStringEnumMemberName("starts_with")]
166+
StartsWith,
167+
168+
/// <summary>
169+
/// Matches if the value does not start with the filter value, ignoring case differences.
170+
/// </summary>
171+
[JsonStringEnumMemberName("not_starts_with")]
172+
NotStartsWith,
173+
174+
/// <summary>
175+
/// Matches if the value ends with the filter value, ignoring case differences.
176+
/// </summary>
177+
[JsonStringEnumMemberName("ends_with")]
178+
EndsWith,
179+
180+
/// <summary>
181+
/// Matches if the value does not end with the filter value, ignoring case differences.
182+
/// </summary>
183+
[JsonStringEnumMemberName("not_ends_with")]
184+
NotEndsWith,
185+
186+
/// <summary>
187+
/// An operator this version of the SDK doesn't recognize. Flags using such an operator can't be evaluated
188+
/// locally and fall back to remote evaluation.
189+
/// </summary>
190+
Unknown
161191
}

src/PostHog/Features/LocalEvaluator.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ internal sealed class LocalEvaluator
3030
readonly ReadOnlyDictionary<long, FilterSet> _cohortFilters;
3131
readonly ReadOnlyDictionary<long, string> _groupTypeMapping;
3232

33+
const double LongScale = 0xFFFFFFFFFFFFFFF;
34+
3335
/// <summary>
3436
/// Constructs a <see cref="LocalEvaluator"/> with the specified flags.
3537
/// </summary>
@@ -671,6 +673,10 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
671673
ComparisonOperator.LessThanOrEquals => value >= overrideValue,
672674
ComparisonOperator.ContainsIgnoreCase => value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
673675
ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
676+
ComparisonOperator.StartsWith => value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
677+
ComparisonOperator.NotStartsWith => !value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
678+
ComparisonOperator.EndsWith => value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
679+
ComparisonOperator.NotEndsWith => !value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
674680
ComparisonOperator.Regex => value.IsRegexMatch(overrideValue),
675681
ComparisonOperator.NotRegex => !value.IsRegexMatch(overrideValue),
676682
ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()),
@@ -876,8 +882,6 @@ static double Hash(string key, string distinctId, string salt = "")
876882

877883
return hashVal / LongScale;
878884
}
879-
880-
const double LongScale = 0xFFFFFFFFFFFFFFF;
881885
}
882886

883887
internal static partial class LocalEvaluatorLoggerExtensions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using PostHog.Api;
2+
3+
namespace PostHog.Json;
4+
5+
/// <summary>
6+
/// Converts <see cref="ComparisonOperator"/> values to and from their JSON wire names. Operator names this
7+
/// version of the SDK doesn't recognize map to <see cref="ComparisonOperator.Unknown"/> so that a new
8+
/// server-side operator makes only the affected flag inconclusive instead of failing deserialization of the
9+
/// entire local evaluation response.
10+
/// </summary>
11+
internal sealed class ComparisonOperatorJsonConverter : JsonStringEnumMemberNameJsonConverter<ComparisonOperator>
12+
{
13+
public ComparisonOperatorJsonConverter() : base(ComparisonOperator.Unknown)
14+
{
15+
}
16+
}

src/PostHog/Json/JsonStringEnumMemberNameJsonConverter.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ internal class JsonStringEnumMemberNameJsonConverter<TEnum> : JsonConverter<TEnu
1414
private static readonly Dictionary<string, TEnum> StringToEnum = CreateStringToEnumMapping();
1515
private static readonly Dictionary<TEnum, string> EnumToString = CreateEnumToStringMapping();
1616

17+
private readonly TEnum? _fallbackValue;
18+
19+
public JsonStringEnumMemberNameJsonConverter()
20+
{
21+
}
22+
23+
/// <summary>
24+
/// Initializes a converter that maps unrecognized strings to <paramref name="fallbackValue"/>
25+
/// instead of throwing a <see cref="JsonException"/>.
26+
/// </summary>
27+
/// <param name="fallbackValue">The value to return for strings that don't map to an enum member.</param>
28+
protected JsonStringEnumMemberNameJsonConverter(TEnum fallbackValue)
29+
{
30+
_fallbackValue = fallbackValue;
31+
}
32+
1733
private static Dictionary<string, TEnum> CreateStringToEnumMapping()
1834
{
1935
var mapping = new Dictionary<string, TEnum>();
@@ -61,6 +77,10 @@ public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe
6177
{
6278
return enumValue;
6379
}
80+
if (_fallbackValue is { } fallbackValue)
81+
{
82+
return fallbackValue;
83+
}
6484
throw new JsonException($"Unable to convert \"{stringValue}\" to {typeof(TEnum).Name}.");
6585
}
6686

src/PostHog/Json/PropertyFilterValue.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ public bool IsContainedBy(object? other, StringComparison stringComparison) =>
139139
&& StringValue is not null
140140
&& comparandString.Contains(StringValue, stringComparison);
141141

142+
/// <summary>
143+
/// Returns a value indicating whether this instance is a prefix of the specified <paramref name="other"/> instance.
144+
/// </summary>
145+
/// <param name="other">The other value to compare to this one.</param>
146+
/// <param name="stringComparison">The type of comparison if these are strings.</param>
147+
/// <returns><c>true</c> if the other value starts with this instance.</returns>
148+
public bool IsPrefixOf(object? other, StringComparison stringComparison) =>
149+
other?.ToString() is { } comparandString
150+
&& StringValue is not null
151+
&& comparandString.StartsWith(StringValue, stringComparison);
152+
153+
/// <summary>
154+
/// Returns a value indicating whether this instance is a suffix of the specified <paramref name="other"/> instance.
155+
/// </summary>
156+
/// <param name="other">The other value to compare to this one.</param>
157+
/// <param name="stringComparison">The type of comparison if these are strings.</param>
158+
/// <returns><c>true</c> if the other value ends with this instance.</returns>
159+
public bool IsSuffixOf(object? other, StringComparison stringComparison) =>
160+
other?.ToString() is { } comparandString
161+
&& StringValue is not null
162+
&& comparandString.EndsWith(StringValue, stringComparison);
163+
142164
/// <summary>
143165
/// Determines whether the specified <paramref name="overrideValue"/> is an "exact" match for this instance.
144166
/// If this instance is an array, then it's checking to see if the value is in the array.

src/PostHog/PublicAPI.Unshipped.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
#nullable enable
22
PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool
33
PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void
4+
PostHog.Api.ComparisonOperator.EndsWith = 27 -> PostHog.Api.ComparisonOperator
5+
PostHog.Api.ComparisonOperator.NotEndsWith = 28 -> PostHog.Api.ComparisonOperator
6+
PostHog.Api.ComparisonOperator.NotStartsWith = 26 -> PostHog.Api.ComparisonOperator
7+
PostHog.Api.ComparisonOperator.StartsWith = 25 -> PostHog.Api.ComparisonOperator
8+
PostHog.Api.ComparisonOperator.Unknown = 29 -> PostHog.Api.ComparisonOperator
49
PostHog.Api.FlagsResult.MinimalFlagCalledEvents.get -> bool
510
PostHog.Api.FlagsResult.MinimalFlagCalledEvents.init -> void
611
PostHog.Features.FeatureFlag.HasExperiment.get -> bool?
712
PostHog.Features.FeatureFlag.HasExperiment.init -> void
13+
PostHog.Json.PropertyFilterValue.IsPrefixOf(object? other, System.StringComparison stringComparison) -> bool
14+
PostHog.Json.PropertyFilterValue.IsSuffixOf(object? other, System.StringComparison stringComparison) -> bool
815
PostHog.PostHogOptions.BeforeSend.get -> System.Func<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?
916
PostHog.PostHogOptions.BeforeSend.set -> void
1017
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int

tests/UnitTests/Features/LocalEvaluatorTests.cs

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,127 @@ public void HandlesContainsComparisons(object overrideValue, ComparisonOperator
400400
Assert.Equal(expected, result);
401401
}
402402

403+
[Theory]
404+
[InlineData("value", ComparisonOperator.StartsWith, "\"Val\"", true)]
405+
[InlineData("VALUE", ComparisonOperator.StartsWith, "\"Val\"", true)]
406+
[InlineData("vaLue4", ComparisonOperator.StartsWith, "\"Val\"", true)]
407+
[InlineData("prevalue", ComparisonOperator.StartsWith, "\"Val\"", false)]
408+
[InlineData("Alakazam", ComparisonOperator.StartsWith, "\"Val\"", false)]
409+
[InlineData(323, ComparisonOperator.StartsWith, "\"3\"", true)]
410+
[InlineData(123, ComparisonOperator.StartsWith, "\"3\"", false)]
411+
[InlineData("value", ComparisonOperator.NotStartsWith, "\"Val\"", false)]
412+
[InlineData("VALUE", ComparisonOperator.NotStartsWith, "\"Val\"", false)]
413+
[InlineData("prevalue", ComparisonOperator.NotStartsWith, "\"Val\"", true)]
414+
[InlineData("Alakazam", ComparisonOperator.NotStartsWith, "\"Val\"", true)]
415+
[InlineData("value", ComparisonOperator.EndsWith, "\"lUe\"", true)]
416+
[InlineData("VALUE", ComparisonOperator.EndsWith, "\"lUe\"", true)]
417+
[InlineData("343tfvalue", ComparisonOperator.EndsWith, "\"lUe\"", true)]
418+
[InlineData("value2", ComparisonOperator.EndsWith, "\"lUe\"", false)]
419+
[InlineData("Alakazam", ComparisonOperator.EndsWith, "\"lUe\"", false)]
420+
[InlineData(323, ComparisonOperator.EndsWith, "\"3\"", true)]
421+
[InlineData(13, ComparisonOperator.EndsWith, "\"3\"", true)]
422+
[InlineData(321, ComparisonOperator.EndsWith, "\"3\"", false)]
423+
[InlineData("value", ComparisonOperator.NotEndsWith, "\"lUe\"", false)]
424+
[InlineData("VALUE", ComparisonOperator.NotEndsWith, "\"lUe\"", false)]
425+
[InlineData("value2", ComparisonOperator.NotEndsWith, "\"lUe\"", true)]
426+
[InlineData("Alakazam", ComparisonOperator.NotEndsWith, "\"lUe\"", true)]
427+
public void HandlesStartsWithAndEndsWithComparisons(object overrideValue, ComparisonOperator comparison, string filterValueJson, bool expected)
428+
{
429+
var flags = CreateFlags(
430+
key: "bio",
431+
properties:
432+
[
433+
new PropertyFilter
434+
{
435+
Type = FilterType.Person,
436+
Key = "bio",
437+
Value = PropertyFilterValue.Create(JsonDocument.Parse(filterValueJson).RootElement)!,
438+
Operator = comparison
439+
}
440+
]
441+
);
442+
var properties = new Dictionary<string, object?>
443+
{
444+
["bio"] = overrideValue
445+
};
446+
var localEvaluator = new LocalEvaluator(flags);
447+
448+
var result = localEvaluator.EvaluateFeatureFlag(
449+
key: "bio",
450+
distinctId: "distinct-id",
451+
personProperties: properties);
452+
453+
Assert.Equal(expected, result);
454+
}
455+
456+
[Theory]
457+
[InlineData(ComparisonOperator.StartsWith)]
458+
[InlineData(ComparisonOperator.NotStartsWith)]
459+
[InlineData(ComparisonOperator.EndsWith)]
460+
[InlineData(ComparisonOperator.NotEndsWith)]
461+
public void ReturnsFalseWhenPropertyValueIsNullForStartsWithAndEndsWithComparisons(ComparisonOperator comparison)
462+
{
463+
var flags = CreateFlags(
464+
key: "bio",
465+
properties:
466+
[
467+
new PropertyFilter
468+
{
469+
Type = FilterType.Person,
470+
Key = "bio",
471+
Value = new PropertyFilterValue("Val"),
472+
Operator = comparison
473+
}
474+
]
475+
);
476+
var properties = new Dictionary<string, object?>
477+
{
478+
["bio"] = null
479+
};
480+
var localEvaluator = new LocalEvaluator(flags);
481+
482+
var result = localEvaluator.EvaluateFeatureFlag(
483+
key: "bio",
484+
distinctId: "distinct-id",
485+
personProperties: properties);
486+
487+
// A null property value fails the comparison for both the positive and not_ variants.
488+
Assert.False(result.Value);
489+
}
490+
491+
[Theory]
492+
[InlineData(ComparisonOperator.StartsWith)]
493+
[InlineData(ComparisonOperator.NotStartsWith)]
494+
[InlineData(ComparisonOperator.EndsWith)]
495+
[InlineData(ComparisonOperator.NotEndsWith)]
496+
public void ThrowsInconclusiveMatchExceptionWhenPropertyKeyMissingForStartsWithAndEndsWithComparisons(ComparisonOperator comparison)
497+
{
498+
var flags = CreateFlags(
499+
key: "bio",
500+
properties:
501+
[
502+
new PropertyFilter
503+
{
504+
Type = FilterType.Person,
505+
Key = "bio",
506+
Value = new PropertyFilterValue("Val"),
507+
Operator = comparison
508+
}
509+
]
510+
);
511+
var properties = new Dictionary<string, object?>
512+
{
513+
["other_property"] = "value"
514+
};
515+
var localEvaluator = new LocalEvaluator(flags);
516+
517+
Assert.Throws<InconclusiveMatchException>(() =>
518+
localEvaluator.EvaluateFeatureFlag(
519+
key: "bio",
520+
distinctId: "distinct-id",
521+
personProperties: properties));
522+
}
523+
403524
[Theory]
404525
[InlineData(22, ComparisonOperator.GreaterThan, "\"21\"", true)]
405526
[InlineData(22, ComparisonOperator.GreaterThanOrEquals, "\"21\"", true)]
@@ -716,8 +837,10 @@ public void ThrowsInconclusiveMatchExceptionWhenFilterValueNotDate(ComparisonOpe
716837
});
717838
}
718839

719-
[Fact]
720-
public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator()
840+
[Theory]
841+
[InlineData((ComparisonOperator)999)]
842+
[InlineData(ComparisonOperator.Unknown)]
843+
public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator(ComparisonOperator comparison)
721844
{
722845
var properties = new Dictionary<string, object?>
723846
{
@@ -731,7 +854,7 @@ public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator()
731854
Type = FilterType.Person,
732855
Key = "join_date",
733856
Value = new PropertyFilterValue("2025-01-01"),
734-
Operator = (ComparisonOperator)999
857+
Operator = comparison
735858
}
736859
]
737860
);

tests/UnitTests/Json/FilterSerializationTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,44 @@ public async Task CanDeserializePropertyFilter()
6161
propertyFilter);
6262
}
6363

64+
[Theory]
65+
[InlineData("starts_with", ComparisonOperator.StartsWith)]
66+
[InlineData("not_starts_with", ComparisonOperator.NotStartsWith)]
67+
[InlineData("ends_with", ComparisonOperator.EndsWith)]
68+
[InlineData("not_ends_with", ComparisonOperator.NotEndsWith)]
69+
public async Task CanDeserializeStartsWithAndEndsWithOperators(string wireName, ComparisonOperator expected)
70+
{
71+
var json = $$"""
72+
{
73+
"key": "email",
74+
"type": "person",
75+
"value": "posthog",
76+
"operator": "{{wireName}}"
77+
}
78+
""";
79+
var result = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<Filter>(json);
80+
81+
var propertyFilter = Assert.IsType<PropertyFilter>(result);
82+
Assert.Equal(expected, propertyFilter.Operator);
83+
}
84+
85+
[Fact]
86+
public async Task DeserializesUnrecognizedOperatorAsUnknown()
87+
{
88+
var json = """
89+
{
90+
"key": "email",
91+
"type": "person",
92+
"value": "posthog",
93+
"operator": "future_operator"
94+
}
95+
""";
96+
var result = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<Filter>(json);
97+
98+
var propertyFilter = Assert.IsType<PropertyFilter>(result);
99+
Assert.Equal(ComparisonOperator.Unknown, propertyFilter.Operator);
100+
}
101+
64102
[Fact]
65103
public async Task CanDeserializeFilterGroup()
66104
{

0 commit comments

Comments
 (0)