Skip to content

Commit f3a8bb6

Browse files
authored
Merge pull request #162 from PostHog/haacked/netstandard2.0
Add netstandard2.0 target for .NET Framework 4.6.2+ support
2 parents 2bac535 + c29a2b0 commit f3a8bb6

12 files changed

Lines changed: 300 additions & 20 deletions

File tree

src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ private static List<Dictionary<string, object>> BuildStackFrameList(Exception ex
124124
continue;
125125
}
126126

127-
var sourceContext = BuildSourceCodeContext(fileName, lineNumber, columnNumber,
127+
var sourceContext = BuildSourceCodeContext(fileName!, lineNumber, columnNumber,
128128
DEFAULT_MAX_LINES, DEFAULT_MAX_LENGTH);
129129

130130
frameDetails["pre_context"] = sourceContext.PreContext ?? [];

src/PostHog/Features/Group.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public Group(string groupType, string groupKey, IReadOnlyDictionary<string, obje
4949
/// <param name="properties"></param>
5050
public Group(IReadOnlyDictionary<string, object?> properties)
5151
{
52-
Properties = new Dictionary<string, object?>(properties);
52+
Properties = properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
5353
}
5454

5555
/// <summary>

src/PostHog/Features/IFeatureFlagCache.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,18 @@ Task<IReadOnlyDictionary<string, FeatureFlag>> GetAndCacheFeatureFlagsAsync(
3636
/// <param name="cancellationToken">The cancellation token that can be used to cancel the operation.</param>
3737
/// <returns>The set of feature flags.</returns>
3838
[Obsolete("Use GetAndCacheFlagsAsync overload that accepts personProperties and groups to ensure correct cache keys. This method will be removed in a future version.")]
39-
async Task<FlagsResult> GetAndCacheFlagsAsync(
39+
// netstandard2.0 does not support default interface methods (DIM), so the default
40+
// implementations below are conditionally compiled. All concrete implementations
41+
// (FeatureFlagCacheBase, NullFeatureFlagCache, HttpContextFeatureFlagCache) provide
42+
// explicit implementations of every method, so the DIM bodies are never invoked at runtime.
43+
#if !NETSTANDARD2_0
44+
async
45+
#endif
46+
Task<FlagsResult> GetAndCacheFlagsAsync(
4047
string distinctId,
4148
Func<string, CancellationToken, Task<FlagsResult>> fetcher,
4249
CancellationToken cancellationToken)
50+
#if !NETSTANDARD2_0
4351
{
4452
return new FlagsResult
4553
{
@@ -56,6 +64,9 @@ async Task<FlagsResult> GetAndCacheFlagsAsync(
5664
)
5765
};
5866
}
67+
#else
68+
;
69+
#endif
5970

6071
/// <summary>
6172
/// Attempts to retrieve the flags API result. If the feature flags are not in the cache, then
@@ -71,17 +82,24 @@ async Task<FlagsResult> GetAndCacheFlagsAsync(
7182
/// <param name="fetcher">The feature flag fetcher.</param>
7283
/// <param name="cancellationToken">The cancellation token that can be used to cancel the operation.</param>
7384
/// <returns>The set of feature flags.</returns>
74-
async Task<FlagsResult> GetAndCacheFlagsAsync(
85+
#if !NETSTANDARD2_0
86+
async
87+
#endif
88+
Task<FlagsResult> GetAndCacheFlagsAsync(
7589
string distinctId,
7690
IReadOnlyDictionary<string, object?>? personProperties,
7791
GroupCollection? groups,
7892
Func<string, CancellationToken, Task<FlagsResult>> fetcher,
7993
CancellationToken cancellationToken)
94+
#if !NETSTANDARD2_0
8095
{
8196
// Default implementation: no caching to avoid incorrect cache keys
8297
// Implementations should override this to provide proper caching
8398
return await NotNull(fetcher)(distinctId, cancellationToken);
8499
}
100+
#else
101+
;
102+
#endif
85103
}
86104

87105
/// <summary>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#if NETSTANDARD2_0
2+
// ReSharper disable once CheckNamespace
3+
namespace System.Collections.Generic;
4+
5+
internal static class DictionaryPolyfills
6+
{
7+
public static bool TryAdd<TKey, TValue>(this Dictionary<TKey, TValue> dictionary, TKey key, TValue value)
8+
where TKey : notnull
9+
{
10+
if (dictionary.ContainsKey(key))
11+
{
12+
return false;
13+
}
14+
15+
dictionary.Add(key, value);
16+
return true;
17+
}
18+
19+
public static TValue? GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
20+
where TKey : notnull
21+
{
22+
return dictionary.TryGetValue(key, out var value) ? value : default;
23+
}
24+
25+
public static TValue GetValueOrDefault<TKey, TValue>(
26+
this IReadOnlyDictionary<TKey, TValue> dictionary,
27+
TKey key,
28+
TValue defaultValue)
29+
where TKey : notnull
30+
{
31+
return dictionary.TryGetValue(key, out var value) ? value : defaultValue;
32+
}
33+
34+
public static void Deconstruct<TKey, TValue>(this KeyValuePair<TKey, TValue> pair, out TKey key, out TValue value)
35+
{
36+
key = pair.Key;
37+
value = pair.Value;
38+
}
39+
}
40+
#endif
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#if NETSTANDARD2_0
2+
using System.Runtime.CompilerServices;
3+
4+
// ReSharper disable once CheckNamespace
5+
namespace System;
6+
7+
/// <summary>
8+
/// Polyfill for System.Index to enable C# 8 index syntax (e.g., ^1) on netstandard2.0.
9+
/// </summary>
10+
internal readonly struct Index : IEquatable<Index>
11+
{
12+
readonly int _value;
13+
14+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
15+
public Index(int value, bool fromEnd = false)
16+
{
17+
if (value < 0)
18+
{
19+
throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative");
20+
}
21+
22+
_value = fromEnd ? ~value : value;
23+
}
24+
25+
Index(int value)
26+
{
27+
_value = value;
28+
}
29+
30+
public static Index Start => new(0);
31+
public static Index End => new(~0);
32+
33+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
34+
public static Index FromStart(int value)
35+
{
36+
if (value < 0)
37+
{
38+
throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative");
39+
}
40+
41+
return new Index(value);
42+
}
43+
44+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
45+
public static Index FromEnd(int value)
46+
{
47+
if (value < 0)
48+
{
49+
throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative");
50+
}
51+
52+
return new Index(~value);
53+
}
54+
55+
public int Value => _value < 0 ? ~_value : _value;
56+
public bool IsFromEnd => _value < 0;
57+
58+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
59+
public int GetOffset(int length)
60+
{
61+
var offset = _value;
62+
if (IsFromEnd)
63+
{
64+
offset += length + 1;
65+
}
66+
67+
return offset;
68+
}
69+
70+
public override bool Equals(object? value) => value is Index index && _value == index._value;
71+
public bool Equals(Index other) => _value == other._value;
72+
public override int GetHashCode() => _value;
73+
74+
public static implicit operator Index(int value) => FromStart(value);
75+
}
76+
77+
/// <summary>
78+
/// Polyfill for System.Range to enable C# 8 range syntax (e.g., 1..^1) on netstandard2.0.
79+
/// </summary>
80+
internal readonly struct Range : IEquatable<Range>
81+
{
82+
public Index Start { get; }
83+
public Index End { get; }
84+
85+
public Range(Index start, Index end)
86+
{
87+
Start = start;
88+
End = end;
89+
}
90+
91+
public static Range StartAt(Index start) => new(start, Index.End);
92+
public static Range EndAt(Index end) => new(Index.Start, end);
93+
public static Range All => new(Index.Start, Index.End);
94+
95+
public override bool Equals(object? value) => value is Range r && r.Start.Equals(Start) && r.End.Equals(End);
96+
public bool Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End);
97+
public override int GetHashCode() => Start.GetHashCode() * 31 + End.GetHashCode();
98+
99+
public (int Offset, int Length) GetOffsetAndLength(int length)
100+
{
101+
var start = Start.GetOffset(length);
102+
var end = End.GetOffset(length);
103+
if ((uint)end > (uint)length || (uint)start > (uint)end)
104+
{
105+
throw new ArgumentOutOfRangeException(nameof(length));
106+
}
107+
108+
return (start, end - start);
109+
}
110+
}
111+
112+
internal static class RuntimeCompatibilityExtensions
113+
{
114+
public static string Substring(this string s, Range range)
115+
{
116+
var (offset, length) = range.GetOffsetAndLength(s.Length);
117+
return s.Substring(offset, length);
118+
}
119+
}
120+
#endif
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#if NETSTANDARD2_0
2+
// ReSharper disable once CheckNamespace
3+
namespace System;
4+
5+
internal static class StringPolyfills
6+
{
7+
public static bool Contains(this string source, string value, StringComparison comparisonType)
8+
{
9+
return source.IndexOf(value, comparisonType) >= 0;
10+
}
11+
12+
public static string Replace(this string source, string oldValue, string? newValue, StringComparison comparisonType)
13+
{
14+
if (string.IsNullOrEmpty(oldValue))
15+
{
16+
throw new ArgumentException("String cannot be of zero length.", nameof(oldValue));
17+
}
18+
19+
if (comparisonType == StringComparison.Ordinal)
20+
{
21+
return source.Replace(oldValue, newValue ?? string.Empty);
22+
}
23+
24+
var result = new Text.StringBuilder();
25+
var searchIndex = 0;
26+
27+
while (searchIndex < source.Length)
28+
{
29+
var matchIndex = source.IndexOf(oldValue, searchIndex, comparisonType);
30+
if (matchIndex < 0)
31+
{
32+
result.Append(source, searchIndex, source.Length - searchIndex);
33+
break;
34+
}
35+
36+
result.Append(source, searchIndex, matchIndex - searchIndex);
37+
result.Append(newValue);
38+
searchIndex = matchIndex + oldValue.Length;
39+
}
40+
41+
return result.ToString();
42+
}
43+
44+
public static int GetHashCode(this string source, StringComparison comparisonType)
45+
{
46+
return GetStringComparer(comparisonType).GetHashCode(source);
47+
}
48+
49+
static StringComparer GetStringComparer(StringComparison comparisonType)
50+
{
51+
return comparisonType switch
52+
{
53+
StringComparison.CurrentCulture => StringComparer.CurrentCulture,
54+
StringComparison.CurrentCultureIgnoreCase => StringComparer.CurrentCultureIgnoreCase,
55+
StringComparison.InvariantCulture => StringComparer.InvariantCulture,
56+
StringComparison.InvariantCultureIgnoreCase => StringComparer.InvariantCultureIgnoreCase,
57+
StringComparison.Ordinal => StringComparer.Ordinal,
58+
StringComparison.OrdinalIgnoreCase => StringComparer.OrdinalIgnoreCase,
59+
_ => throw new ArgumentException($"Unknown StringComparison value: {comparisonType}", nameof(comparisonType))
60+
};
61+
}
62+
}
63+
#endif

src/PostHog/PostHog.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>netstandard2.1;net8.0</TargetFrameworks>
4+
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0</TargetFrameworks>
55
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
66
<PackageId>PostHog</PackageId>
77
<Description>A library for interacting with the PostHog Analytics Platform.</Description>
@@ -20,6 +20,10 @@
2020
<PackageReference Include="Microsoft.Bcl.TimeProvider" Version="8.0.1" />
2121
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
2222
</ItemGroup>
23+
24+
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
25+
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
26+
</ItemGroup>
2327

2428
<ItemGroup>
2529
<None Include="README.md" Pack="true" PackagePath="\"/>

tests/TestLibrary/Fakes/FakeHttpMessageHandler.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,9 @@ async Task<bool> Predicate(HttpRequestMessage request) =>
224224
public async Task<HttpResponseMessage> Respond(HttpRequestMessage requestMessage)
225225
{
226226
Debug.Assert(requestMessage != null, nameof(requestMessage) + " != null");
227-
_receivedRequests.Add(requestMessage);
228-
if (requestMessage.Content is not null)
227+
// Null-forgiving: Debug.Assert ensures non-null, but ns2.0 lacks [DoesNotReturnIf] annotation
228+
_receivedRequests.Add(requestMessage!);
229+
if (requestMessage!.Content is not null)
229230
{
230231
_receivedBodiesJson.Add(await requestMessage.Content.ReadAsStringAsync());
231232
}

tests/TestLibrary/Fakes/FakeLoggerProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ bool StateMatches(object? argState, IReadOnlyDictionary<string, object> expected
7878
{
7979
return false;
8080
}
81-
var argDict = new Dictionary<string, object>(pairs);
81+
var argDict = pairs.ToDictionary(kv => kv.Key, kv => kv.Value);
8282
foreach (var (expectedKey, expectedValue) in expectedParameters)
8383
{
8484
if (!argDict.TryGetValue(expectedKey, out var actualValue)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#if NETSTANDARD2_0
2+
// ReSharper disable once CheckNamespace
3+
namespace System;
4+
5+
internal static class StringPolyfills
6+
{
7+
public static int IndexOf(this string source, char value, StringComparison comparisonType)
8+
{
9+
return source.IndexOf(value.ToString(), comparisonType);
10+
}
11+
}
12+
#endif

0 commit comments

Comments
 (0)