Skip to content

Commit 4f5158f

Browse files
committed
HybridCache: richer detection for field-only types (ref STJ)
fix dotnet/aspnetcore#60934
1 parent 6dec70a commit 4f5158f

File tree

2 files changed

+207
-3
lines changed

2 files changed

+207
-3
lines changed

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs

+95-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33

44
using System;
55
using System.Buffers;
6+
using System.Collections;
7+
using System.Collections.Generic;
68
using System.Diagnostics.CodeAnalysis;
9+
using System.Reflection;
710
using System.Text.Json;
811
using Microsoft.Extensions.DependencyInjection;
912

@@ -35,7 +38,7 @@ public bool TryCreateSerializer<T>([NotNullWhen(true)] out IHybridCacheSerialize
3538

3639
// see if there is a per-type options registered (keyed by the **closed** generic type), otherwise use the default
3740
JsonSerializerOptions options = _serviceProvider.GetKeyedService<JsonSerializerOptions>(typeof(IHybridCacheSerializer<T>)) ?? Options;
38-
if (IsValueTuple(typeof(T)) && !options.IncludeFields)
41+
if (!options.IncludeFields && IsFieldOnlyType(typeof(T)))
3942
{
4043
// value-tuples expose fields, not properties; special-case this as a common scenario
4144
options = FieldEnabledJsonOptions;
@@ -45,8 +48,90 @@ public bool TryCreateSerializer<T>([NotNullWhen(true)] out IHybridCacheSerialize
4548
return true;
4649
}
4750

48-
private static bool IsValueTuple(Type type)
49-
=> type.IsValueType && (type.FullName ?? string.Empty).StartsWith("System.ValueTuple`", StringComparison.Ordinal);
51+
internal static bool IsFieldOnlyType(Type type)
52+
{
53+
Dictionary<Type, FieldOnlyResult>? state = null; // only needed for complex types
54+
return IsFieldOnlyType(type, ref state) == FieldOnlyResult.FieldOnly;
55+
}
56+
57+
[SuppressMessage("Trimming", "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of method does not have matching annotations.",
58+
Justification = "Custom serializers may be needed for AOT with STJ")]
59+
[SuppressMessage("Performance", "CA1864:Prefer the 'IDictionary.TryAdd(TKey, TValue)' method", Justification = "Not available in all platforms")]
60+
private static FieldOnlyResult IsFieldOnlyType(
61+
Type type, ref Dictionary<Type, FieldOnlyResult>? state)
62+
{
63+
if (type is null || type.IsPrimitive || type == typeof(string))
64+
{
65+
return FieldOnlyResult.NotFieldOnly;
66+
}
67+
68+
// re-use existing results, and more importantly: prevent infinite recursion
69+
if (state is not null && state.TryGetValue(type, out var existingResult))
70+
{
71+
return existingResult;
72+
}
73+
74+
// check for collection types; start at IEnumerable and then look for IEnumerable<T>
75+
// (this is broadly comparable to STJ)
76+
if (typeof(IEnumerable).IsAssignableFrom(type))
77+
{
78+
PrepareStateForDepth(type, ref state);
79+
foreach (var iType in type.GetInterfaces())
80+
{
81+
if (iType.IsGenericType && iType.GetGenericTypeDefinition() == typeof(IEnumerable<>))
82+
{
83+
if (IsFieldOnlyType(iType.GetGenericArguments()[0], ref state) == FieldOnlyResult.FieldOnly)
84+
{
85+
return SetState(type, state, true);
86+
}
87+
}
88+
}
89+
90+
// no problems detected
91+
return SetState(type, state, false);
92+
}
93+
94+
// not a collection; check for field-only scenario - look for properties first
95+
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
96+
if (props.Length != 0)
97+
{
98+
PrepareStateForDepth(type, ref state);
99+
foreach (var prop in props)
100+
{
101+
if (IsFieldOnlyType(prop.PropertyType, ref state) == FieldOnlyResult.FieldOnly)
102+
{
103+
return SetState(type, state, true);
104+
}
105+
}
106+
107+
// then we *do* have public instance properties, that aren't themselves problems; we're good
108+
return SetState(type, state, false);
109+
}
110+
111+
// no properties; if there are fields, this is the problem scenario we're trying to detect
112+
var haveFields = type.GetFields(BindingFlags.Public | BindingFlags.Instance).Length != 0;
113+
return SetState(type, state, haveFields);
114+
115+
static void PrepareStateForDepth(Type type, ref Dictionary<Type, FieldOnlyResult>? state)
116+
{
117+
state ??= [];
118+
if (!state.ContainsKey(type))
119+
{
120+
state.Add(type, FieldOnlyResult.Incomplete);
121+
}
122+
}
123+
124+
static FieldOnlyResult SetState(Type type, Dictionary<Type, FieldOnlyResult>? state, bool result)
125+
{
126+
var value = result ? FieldOnlyResult.FieldOnly : FieldOnlyResult.NotFieldOnly;
127+
if (state is not null)
128+
{
129+
state[type] = value;
130+
}
131+
132+
return value;
133+
}
134+
}
50135

51136
internal sealed class DefaultJsonSerializer<T> : IHybridCacheSerializer<T>
52137
{
@@ -76,4 +161,11 @@ void IHybridCacheSerializer<T>.Serialize(T value, IBufferWriter<byte> target)
76161
#pragma warning restore IDE0079
77162
}
78163

164+
// used to store intermediate state when calculating IsFieldOnlyType
165+
private enum FieldOnlyResult
166+
{
167+
Incomplete = 0,
168+
FieldOnly = 1,
169+
NotFieldOnly = 2,
170+
}
79171
}

test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SerializerTests.cs

+112
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,118 @@ public void RoundTripNamedValueTuple(JsonSerializer addSerializers, JsonSerializ
103103
Assert.Equal("abc", obj.Y);
104104
}
105105

106+
[Fact]
107+
public void RoundTripValueTupleList()
108+
{
109+
List<(int, string)> source = [(1, "a"), (2, "b")];
110+
var clone = RoundTrip(source, """[{"Item1":1,"Item2":"a"},{"Item1":2,"Item2":"b"}]"""u8, JsonSerializer.FieldEnabled);
111+
Assert.Equal(source, clone);
112+
}
113+
114+
[Fact]
115+
public void RoundTripValueTupleArray()
116+
{
117+
(int, string)[] source = [(1, "a"), (2, "b")];
118+
var clone = RoundTrip(source, """[{"Item1":1,"Item2":"a"},{"Item1":2,"Item2":"b"}]"""u8, JsonSerializer.FieldEnabled);
119+
Assert.Equal(source, clone);
120+
}
121+
122+
[Fact]
123+
public void RoundTripTupleList()
124+
{
125+
List<Tuple<int, string>> source = [Tuple.Create(1, "a"), Tuple.Create(2, "b")];
126+
var clone = RoundTrip(source, """[{"Item1":1,"Item2":"a"},{"Item1":2,"Item2":"b"}]"""u8, JsonSerializer.Default);
127+
Assert.Equal(source, clone);
128+
}
129+
130+
[Fact]
131+
public void RoundTripTupleArray()
132+
{
133+
Tuple<int, string>[] source = [Tuple.Create(1, "a"), Tuple.Create(2, "b")];
134+
var clone = RoundTrip(source, """[{"Item1":1,"Item2":"a"},{"Item1":2,"Item2":"b"}]"""u8, JsonSerializer.Default);
135+
Assert.Equal(source, clone);
136+
}
137+
138+
[Fact]
139+
public void RoundTripFieldOnlyPoco()
140+
{
141+
var source = new FieldOnlyPoco { X = 1, Y = "a" };
142+
var clone = RoundTrip(source, """{"X":1,"Y":"a"}"""u8, JsonSerializer.FieldEnabled);
143+
Assert.Equal(1, clone.X);
144+
Assert.Equal("a", clone.Y);
145+
}
146+
147+
[Fact]
148+
public void RoundTripPropertyOnlyPoco()
149+
{
150+
var source = new PropertyOnlyPoco { X = 1, Y = "a" };
151+
var clone = RoundTrip(source, """{"X":1,"Y":"a"}"""u8, JsonSerializer.Default);
152+
Assert.Equal(1, clone.X);
153+
Assert.Equal("a", clone.Y);
154+
}
155+
156+
[Fact]
157+
public void RoundTripMixedPoco()
158+
{
159+
// this is the self-inflicted scenario; intent isn't obvious, so: we defer to STJ conventions,
160+
// which means we lose the field
161+
var source = new MixedFieldPropertyPoco { X = 1, Y = "a" };
162+
var clone = RoundTrip(source, """{"Y":"a"}"""u8, JsonSerializer.Default);
163+
Assert.Equal(0, clone.X); // <== drop
164+
Assert.Equal("a", clone.Y);
165+
}
166+
167+
[Fact]
168+
public void RoundTripTree()
169+
{
170+
NodeA<string> source = new NodeA<string>
171+
{
172+
Value = "abc",
173+
Next = new()
174+
{
175+
Value = "def"
176+
}
177+
};
178+
179+
var clone = RoundTrip(source, """{"Next":{"Next":null,"Value":"def"},"Value":"abc"}"""u8, JsonSerializer.Default);
180+
Assert.Equal("abc", clone.Value);
181+
Assert.NotNull(clone.Next);
182+
Assert.Equal("def", clone.Next.Value);
183+
Assert.Null(clone.Next.Next);
184+
}
185+
186+
public class FieldOnlyPoco
187+
{
188+
public int X;
189+
public string? Y;
190+
}
191+
192+
public class PropertyOnlyPoco
193+
{
194+
public int X { get; set; }
195+
public string? Y { get; set; }
196+
}
197+
198+
public class MixedFieldPropertyPoco
199+
{
200+
public int X; // field
201+
public string? Y { get; set; } // property
202+
}
203+
204+
public class NodeA<T>
205+
{
206+
public NodeB<T>? Next { get; set; }
207+
208+
public T? Value { get; set; }
209+
}
210+
211+
public class NodeB<T>
212+
{
213+
public NodeA<T>? Next { get; set; }
214+
215+
public T? Value { get; set; }
216+
}
217+
106218
private static T RoundTrip<T>(T value, ReadOnlySpan<byte> expectedBytes, JsonSerializer expectedJsonOptions, JsonSerializer addSerializers = JsonSerializer.None, bool binary = false)
107219
{
108220
var services = new ServiceCollection();

0 commit comments

Comments
 (0)