Skip to content

Commit d356b83

Browse files
authored
Implement [NullWrappedCollection] (#1044)
* initial test * use manually implented equivalent to nail expected bytes * implement (watch for CS0618) serializer core * null-wrapped check should preceed other lookups * tests and fixes for wrapped sub-items (turns out it didn't work correctly for the writer API) * reset in WriteEmptyWrappedItem * keep new interface internal for now * release notes * add map support for [ProtoWrappedCollection] (not not [ProtoWrappedValue]) * fix RepeatedSerializer<,> - dropped wrong interface by mistake * fix broken default creation path in map path * - more map tests and byte analysis - implemented [NullWrappedValue] properly on maps - detect usage in schema-gen - include tests for nulls in the tests - fix PEVerify dll naming issue in coll/map tests * - only compile the model to disk for the zero case (avoid IO conflict) - include more vanilla model tests
1 parent 85949a3 commit d356b83

19 files changed

+1580
-70
lines changed

docs/nullwrappers.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Support for `null` values and empty collections
22

3-
For many commaon scenarios, protobuf-net *just works* with null values; for example:
3+
For many common scenarios, protobuf-net *just works* with null values; for example:
44

55
``` c#
66
[ProtoMember(5)]
@@ -22,7 +22,7 @@ needs to consider multiple things:
2222
- the later addition of "field presence" to `proto3`
2323
- the existence of `wrappers.proto`
2424

25-
I'm not going to attempt to cover all of these things in detail! However, we can give a flavor. If we just consider `proto3`, and a
25+
I'm not going to attempt to cover all of these things in detail! However, we can give a flavour. If we just consider `proto3`, and a
2626
simple integer:
2727

2828
``` proto
@@ -163,7 +163,7 @@ class SomeMessage
163163

164164
In the above, we can see that `[NullWrappedValue]` applies to the *values* in a collection; sometimes - much more rarely - we
165165
wish to apply the same logic to the *collection itself*, to distinguish *null* collections from *empty* collections. Protobuf has
166-
no way of expressing an empty collection, but we can artifically invent an additional message layer that *has* a collection
166+
no way of expressing an empty collection, but we can artificially invent an additional message layer that *has* a collection
167167

168168
- for null collections, nothing is written
169169
- for empty collections, an empty message wrapper is written
@@ -181,7 +181,7 @@ class SomeMessage
181181
```
182182

183183
As before, a runtime fault is thrown if `[NullWrappedCollection]` is encountered on an unexpected type; `[NullWrappedCollection]` *also*
184-
supports the same `AsGroup` concept (although it is not expected to be used in many scenarios). Convenienty, `[NullWrappedCollection]`
184+
supports the same `AsGroup` concept (although it is not expected to be used in many scenarios). Conveniently, `[NullWrappedCollection]`
185185
can be combined with `[NullWrappedValue]` without difficulty, as they apply at different scopes.
186186

187187
With these features, most common null scenarios can be conveniently and robustly handled.

docs/releasenotes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Packages are available on NuGet: [protobuf-net](https://www.nuget.org/packages/p
1414

1515
## unreleased
1616

17+
- implement `[NullWrappedCollection]`, usage [as here](https://protobuf-net.github.io/protobuf-net/nullwrappers#null-collections) (#1044)
1718
- support `nint` (`IntPtr`) and `nuint` (`UIntPtr`) with layout per `long`/`ulong` (#1043; fixes #1042, fixes grpc 282)
1819

1920
## 3.2.12

src/protobuf-net.Core/Internal/KeyValuePairSerializer.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using ProtoBuf.Meta;
22
using ProtoBuf.Serializers;
3+
using System;
34
using System.Collections.Generic;
45
using System.Runtime.InteropServices;
56

@@ -45,12 +46,36 @@ public KeyValuePair<TKey, TValue> Read(ref ProtoReader.State state, KeyValuePair
4546
}
4647
}
4748
if (TypeHelper<TKey>.IsReferenceType && TypeHelper<TKey>.ValueChecker.IsNull(key))
48-
key = TypeModel.CreateInstance<TKey>(state.Context, _keySerializer);
49+
key = CreateDefault<TKey>(state.Context, _keySerializer, _keyFeatures);
4950
if (TypeHelper<TValue>.IsReferenceType && TypeHelper<TValue>.ValueChecker.IsNull(value))
50-
value = TypeModel.CreateInstance<TValue>(state.Context, _valueSerializer);
51+
value = CreateDefault<TValue>(state.Context, _valueSerializer, _valueFeatures);
5152

5253
return new KeyValuePair<TKey, TValue>(key, value);
5354
}
55+
static T CreateDefault<T>(ISerializationContext context, ISerializer<T> serializer, SerializerFeatures features)
56+
{
57+
if (features.HasAny(SerializerFeatures.OptionWrappedValue))
58+
{ // no field? treat as null
59+
return default;
60+
}
61+
if (serializer is not null && serializer.Features.GetCategory() == SerializerFeatures.CategoryMessage)
62+
{
63+
// get the serializer to do the work, by reading an empty payload
64+
// (zero bytes is a default object in protobuf)
65+
// this is useful in case the type is using a non-trivial constructor
66+
// or factory API
67+
var state = ProtoReader.State.Create(default(ReadOnlyMemory<byte>), context?.Model, context?.UserState);
68+
try
69+
{
70+
return serializer.Read(ref state, default);
71+
}
72+
finally
73+
{
74+
state.Dispose();
75+
}
76+
}
77+
return TypeModel.CreateInstance<T>(context, serializer);
78+
}
5479

5580
public void Write(ref ProtoWriter.State state, KeyValuePair<TKey, TValue> value)
5681
{

src/protobuf-net.Core/Meta/TypeModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,7 @@ internal static T CreateInstance<T>(ISerializationContext context, ISerializer<T
14071407
serializer ??= TypeModel.TryGetSerializer<T>(context?.Model);
14081408
T obj = default;
14091409
if (serializer is IFactory<T> factory) obj = factory.Create(context);
1410+
14101411
// note we already know this is a ref-type
14111412
if (obj is null) obj = ActivatorCreate<T>();
14121413
return obj;

src/protobuf-net.Core/NullWrappedValueAttribute.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace ProtoBuf
1111
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
1212
public sealed class NullWrappedValueAttribute : Attribute
1313
{
14-
/// <summary>Indicates that the collection message wrapper shold use group encoding; this is more
14+
/// <summary>Indicates that the collection message wrapper should use group encoding; this is more
1515
/// efficient to write, but may be hard to consume in cross-platform scenarios; this feature is
1616
/// usually used for compatibility with protobuf-net v2 <c>SupportNull</c> usage</summary>
1717
public bool AsGroup { get; set; }
@@ -21,9 +21,9 @@ public sealed class NullWrappedValueAttribute : Attribute
2121
/// see https://protobuf-net.github.io/protobuf-net/nullwrappers for more information.
2222
/// </summary>
2323
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
24-
internal sealed class NullWrappedCollectionAttribute : Attribute
24+
public sealed class NullWrappedCollectionAttribute : Attribute
2525
{
26-
/// <summary>Indicates that the collection message wrapper shold use group encoding; this is more efficient to write, but may be hard to consume in cross-platform scenarios.</summary>
26+
/// <summary>Indicates that the collection message wrapper should use group encoding; this is more efficient to write, but may be hard to consume in cross-platform scenarios.</summary>
2727
public bool AsGroup { get; set; }
2828
}
2929
}

src/protobuf-net.Core/ProtoWriter.BufferWriter.cs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Buffers;
66
using System.Collections.Generic;
7+
using System.Diagnostics;
78
using System.IO;
89
using System.Runtime.CompilerServices;
910

@@ -14,7 +15,7 @@ public partial class ProtoWriter
1415
partial struct State
1516
{
1617
/// <summary>
17-
/// Create a new ProtoWriter that tagets a buffer writer
18+
/// Create a new ProtoWriter that targets a buffer writer
1819
/// </summary>
1920
public static State Create(IBufferWriter<byte> writer, TypeModel model, object userState = null)
2021
=> BufferWriterProtoWriter.CreateBufferWriterProtoWriter(writer, model, userState);
@@ -214,6 +215,113 @@ protected internal override void WriteMessage<T>(ref State state, T value, ISeri
214215
}
215216
}
216217

218+
internal override void WriteWrappedItem<T>(ref State state, SerializerFeatures features, T value, ISerializer<T> serializer)
219+
{
220+
switch (WireType)
221+
{
222+
case WireType.String:
223+
serializer ??= TypeModel.GetSerializer<T>(Model);
224+
long calculatedLength = MeasureAny<T>(_nullWriter, TypeModel.ListItemTag, features, value, serializer);
225+
226+
// write length-prefix as varint
227+
AdvanceAndReset(ImplWriteVarint64(ref state, (ulong)calculatedLength));
228+
229+
if (calculatedLength != 0)
230+
{
231+
var oldPos = GetPosition(ref state);
232+
state.WriteAny(TypeModel.ListItemTag, features, value, serializer);
233+
var newPos = GetPosition(ref state);
234+
235+
var actualLength = (newPos - oldPos);
236+
if (actualLength != calculatedLength)
237+
{
238+
ThrowHelper.ThrowInvalidOperationException($"Length mismatch; calculated '{calculatedLength}', actual '{actualLength}'");
239+
}
240+
}
241+
242+
return;
243+
case WireType.StartGroup:
244+
// forwards-only; can use default implementation
245+
base.WriteWrappedItem<T>(ref state, features, value, serializer);
246+
return;
247+
default:
248+
// if we aren't using length-prefix or group... what are we even?
249+
ThrowHelper.ThrowArgumentOutOfRangeException(nameof(WireType));
250+
return;
251+
}
252+
}
253+
254+
internal override void WriteWrappedCollection<TCollection, TItem>(ref State state, SerializerFeatures features, TCollection values, RepeatedSerializer<TCollection, TItem> serializer, ISerializer<TItem> valueSerializer)
255+
{
256+
switch (WireType)
257+
{
258+
case WireType.String:
259+
valueSerializer ??= TypeModel.GetSerializer<TItem>(Model);
260+
long calculatedLength = MeasureRepeated<TCollection, TItem>(_nullWriter, TypeModel.ListItemTag, features, values, serializer, valueSerializer);
261+
262+
// write length-prefix as varint
263+
AdvanceAndReset(ImplWriteVarint64(ref state, (ulong)calculatedLength));
264+
265+
if (calculatedLength != 0)
266+
{
267+
var oldPos = GetPosition(ref state);
268+
serializer.WriteRepeated(ref state, TypeModel.ListItemTag, features, values, valueSerializer);
269+
var newPos = GetPosition(ref state);
270+
271+
var actualLength = (newPos - oldPos);
272+
if (actualLength != calculatedLength)
273+
{
274+
ThrowHelper.ThrowInvalidOperationException($"Length mismatch; calculated '{calculatedLength}', actual '{actualLength}'");
275+
}
276+
}
277+
278+
return;
279+
case WireType.StartGroup:
280+
// forwards-only; can use default implementation
281+
base.WriteWrappedCollection<TCollection, TItem>(ref state, features, values, serializer, valueSerializer);
282+
return;
283+
default:
284+
// if we aren't using length-prefix or group... what are we even?
285+
ThrowHelper.ThrowArgumentOutOfRangeException(nameof(WireType));
286+
return;
287+
}
288+
}
289+
290+
internal override void WriteWrappedMap<TCollection, TKey, TValue>(ref State state, SerializerFeatures features, TCollection values, MapSerializer<TCollection, TKey, TValue> serializer, SerializerFeatures keyFeatures, SerializerFeatures valueFeatures, ISerializer<TKey> keySerializer, ISerializer<TValue> valueSerializer)
291+
{
292+
switch (WireType)
293+
{
294+
case WireType.String:
295+
long calculatedLength = MeasureMap<TCollection, TKey, TValue>(_nullWriter, TypeModel.ListItemTag, features, values, serializer, keyFeatures, valueFeatures, keySerializer, valueSerializer);
296+
297+
// write length-prefix as varint
298+
AdvanceAndReset(ImplWriteVarint64(ref state, (ulong)calculatedLength));
299+
300+
if (calculatedLength != 0)
301+
{
302+
var oldPos = GetPosition(ref state);
303+
serializer.WriteMap(ref state, TypeModel.ListItemTag, features, values, keyFeatures, valueFeatures, keySerializer, valueSerializer);
304+
var newPos = GetPosition(ref state);
305+
306+
var actualLength = (newPos - oldPos);
307+
if (actualLength != calculatedLength)
308+
{
309+
ThrowHelper.ThrowInvalidOperationException($"Length mismatch; calculated '{calculatedLength}', actual '{actualLength}'");
310+
}
311+
}
312+
313+
return;
314+
case WireType.StartGroup:
315+
// forwards-only; can use default implementation
316+
base.WriteWrappedMap(ref state, features, values, serializer, keyFeatures, valueFeatures, keySerializer, valueSerializer);
317+
return;
318+
default:
319+
// if we aren't using length-prefix or group... what are we even?
320+
ThrowHelper.ThrowArgumentOutOfRangeException(nameof(WireType));
321+
return;
322+
}
323+
}
324+
217325
protected internal override void WriteSubType<T>(ref State state, T value, ISubTypeSerializer<T> serializer)
218326
{
219327
switch (WireType)

src/protobuf-net.Core/ProtoWriter.Null.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,27 @@ private void CheckOversized(ref State state)
7878

7979
protected internal override void WriteMessage<T>(ref State state, T value, ISerializer<T> serializer, PrefixStyle style, bool recursionCheck)
8080
{
81-
if (serializer is null) serializer = TypeModel.GetSerializer<T>(Model);
82-
var len = Measure<T>(this, value, serializer);
81+
var len = Measure<T>(this, value, serializer ?? TypeModel.GetSerializer<T>(Model));
8382
AdvanceSubMessage(ref state, len, style);
8483
}
84+
85+
internal override void WriteWrappedItem<T>(ref State state, SerializerFeatures features, T value, ISerializer<T> serializer)
86+
{
87+
var len = MeasureAny<T>(this, TypeModel.ListItemTag, features, value, serializer ?? TypeModel.GetSerializer<T>(Model));
88+
AdvanceSubMessage(ref state, len, PrefixStyle.Base128); // only supported styles are group+varint
89+
}
90+
internal override void WriteWrappedCollection<TCollection, TItem>(ref State state, SerializerFeatures features, TCollection values, RepeatedSerializer<TCollection, TItem> serializer, ISerializer<TItem> valueSerializer)
91+
{
92+
var len = MeasureRepeated<TCollection, TItem>(this, TypeModel.ListItemTag, features, values, serializer, valueSerializer ?? TypeModel.GetSerializer<TItem>(Model));
93+
AdvanceSubMessage(ref state, len, PrefixStyle.Base128); // only supported styles are group+varint
94+
}
95+
96+
internal override void WriteWrappedMap<TCollection, TKey, TValue>(ref State state, SerializerFeatures features, TCollection values, MapSerializer<TCollection, TKey, TValue> serializer, SerializerFeatures keyFeatures, SerializerFeatures valueFeatures, ISerializer<TKey> keySerializer, ISerializer<TValue> valueSerializer)
97+
{
98+
var len = MeasureMap<TCollection, TKey, TValue>(this, TypeModel.ListItemTag, features, values, serializer, keyFeatures, valueFeatures, keySerializer, valueSerializer);
99+
AdvanceSubMessage(ref state, len, PrefixStyle.Base128); // only supported styles are group+varint
100+
}
101+
85102
private void AdvanceSubMessage(ref State state, long length, PrefixStyle style)
86103
{
87104
long preamble;

src/protobuf-net.Core/ProtoWriter.State.WriteMethods.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using ProtoBuf.Meta;
33
using ProtoBuf.Serializers;
44
using System;
5+
using System.Diagnostics;
56
using System.Diagnostics.CodeAnalysis;
67
using System.IO;
78
using System.Runtime.CompilerServices;
@@ -340,6 +341,7 @@ public void WriteUInt64(ulong value)
340341
[MethodImpl(ProtoReader.HotPath)]
341342
public void WriteMessage<[DynamicallyAccessedMembers(DynamicAccess.ContractType)] T>(int fieldNumber, SerializerFeatures features, T value, ISerializer<T> serializer = null)
342343
{
344+
Debug.Assert(!features.HasAny(SerializerFeatures.OptionWrappedValue), "wrapped value handling has not been processed correctly");
343345
if (!(TypeHelper<T>.CanBeNull && TypeHelper<T>.ValueChecker.IsNull(value)))
344346
{
345347
WriteFieldHeader(fieldNumber, features.IsGroup() ? WireType.StartGroup : WireType.String);
@@ -408,18 +410,19 @@ static WireType AssertWrappedAndGetWireType(ref SerializerFeatures features,
408410
return;
409411
}
410412
WriteFieldHeader(fieldNumber, wrapperFormat);
411-
#pragma warning disable CS0618 // we don't want to use WriteMessage here; this is a pseudo message layer
412-
var tok = StartSubItem(TypeHelper<T>.IsReferenceType & features.ApplyRecursionCheck() ? (object)value : null, PrefixStyle.Base128);
413-
#pragma warning restore CS0618
413+
414414
if (!isNull && (fieldPresence || TypeHelper<T>.ValueChecker.HasNonTrivialValue(value)))
415415
{ // only write the field if it has a meaningful value (note: we already remove the relevant wrap options,
416416
// so: not recursive); if we're using field-presence, we write any non-null value; otherwise,
417417
// (think 'wrappers.proto') we only write non-zero values
418-
WriteAny<T>(1, features, value, serializer);
418+
419+
Debug.Assert(!features.HasAny(SerializerFeatures.OptionWrappedValue), "should not be wrapped");
420+
GetWriter().WriteWrappedItem<T>(ref this, features, value, serializer);
421+
}
422+
else
423+
{
424+
GetWriter().WriteEmptyWrappedItem(ref this);
419425
}
420-
#pragma warning disable CS0618 // we don't want to use WriteMessage here; this is a pseudo message layer
421-
EndSubItem(tok);
422-
#pragma warning restore CS0618
423426
}
424427

425428
/// <summary>
@@ -430,7 +433,7 @@ static WireType AssertWrappedAndGetWireType(ref SerializerFeatures features,
430433
serializer ??= TypeModel.GetSerializer<T>(Model);
431434
features.InheritFrom(serializer.Features);
432435

433-
if (features.HasAny(SerializerFeatures.OptionWrappedValue | SerializerFeatures.OptionWrappedCollection))
436+
if (features.HasAny(SerializerFeatures.OptionWrappedValue))
434437
{
435438
WriteWrapped<T>(fieldNumber, features, value, serializer);
436439
return;

0 commit comments

Comments
 (0)