diff --git a/docfx/docs/migrating.md b/docfx/docs/migrating.md index a2130a14..bfecc133 100644 --- a/docfx/docs/migrating.md +++ b/docfx/docs/migrating.md @@ -16,7 +16,7 @@ Feature | Nerdbank.MessagePack | MessagePack-CSharp | Optimized for high performance | [✅](performance.md) | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#performance) | Contractless data types | [✅](getting-started.md)[^1] | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#object-serialization) | Attributed data types | [✅](customizing-serialization.md) | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#object-serialization) | -Polymorphic serialization | [✅](unions.md) | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#union) | +Polymorphic serialization | [✅](unions.md) | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#union)[^4] | Skip serializing default values | [✅](xref:Nerdbank.MessagePack.MessagePackSerializer.SerializeDefaultValues) | [❌](https://github.com/MessagePack-CSharp/MessagePack-CSharp/issues/678) | Dynamically use maps or arrays for most compact format | [✅](customizing-serialization.md#array-or-map) | [❌](https://github.com/MessagePack-CSharp/MessagePack-CSharp/issues/1953) | Typeless serialization | ❌ | [✅](https://github.com/MessagePack-CSharp/MessagePack-CSharp?tab=readme-ov-file#typeless) | @@ -41,6 +41,7 @@ Security is a complex subject, and an area where Nerdbank.MessagePack is activel [^1]: Nerdbank.MessagePack's approach is more likely to be correct by default and more flexible to fixing when it is not. [^2]: Although MessagePack-CSharp does not support .NET 8 flavor NativeAOT, it has long-supported Unity's il2cpp runtime, but it requires careful avoidance of dynamic features. [^3]: This hasn't been tested, and even if it works, the level of active support may be limited as the maintainers of Nerdbank.MessagePack do not use Unity. We may accept outside contributions to support it if it isn't onerous to maintain. +[^4]: MessagePack-CSharp is limited to derived types that can be attributed on the base type, whereas Nerdbank.MessagePack allows for dynamically identifying derived types at runtime. ## Migration process @@ -114,8 +115,8 @@ Nerdbank.MessagePack supports this same use case via its @Nerdbank.MessagePack.K ```diff -[Union(0, typeof(MyType1))] -[Union(1, typeof(MyType2))] -+[KnownSubType(0, typeof(MyType1))] -+[KnownSubType(1, typeof(MyType2))] ++[KnownSubType(typeof(MyType1), 0)] ++[KnownSubType(typeof(MyType2), 1)] public interface IMyType { } diff --git a/docfx/docs/unions.md b/docfx/docs/unions.md index 56149e39..5575673d 100644 --- a/docfx/docs/unions.md +++ b/docfx/docs/unions.md @@ -30,6 +30,7 @@ This changes the schema of the serialized data to include a tag that indicates t ``` But with the `KnownSubTypeAttribute`, it serializes like this: + ```json [null, { "Name": "Bessie" }] ``` @@ -69,6 +70,7 @@ Now suppose you have different breeds of horses that each had their own subtype: --- At this point your `HorsePen` *would* serialize with the union schema around each horse: + ```json { "Horses": [[1, { "Name": "Bessie" }], [2, { "Name", "Lightfoot" }]] } ``` @@ -79,6 +81,61 @@ The `Animal` class only knows about `Horse` as a subtype and designates `2` as t As such, serializing your `Farm` would drop any details about horse breeds and deserializing would produce `Horse` objects, not `QuarterHorse` or `Thoroughbred`. To fix this, you would need to add @Nerdbank.MessagePack.KnownSubTypeAttribute`1 to the `Animal` class for `QuarterHorse` and `Thoroughbred` that assigns type aliases for each of them. +### Alias types + +An alias may be an integer or a string. +String aliases are case sensitive. + +Aliases may also be inferred from the @System.Type.FullName?displayProperty=nameWithType of the sub-type, in which case they are treated as strings. + +The following example shows using strings: + +# [.NET](#tab/net) + +[!code-csharp[](../../samples/Unions.cs#StringAliasTypesNET)] + +# [.NET Standard](#tab/netfx) + +[!code-csharp[](../../samples/Unions.cs#StringAliasTypesNETFX)] + +--- + +Mixing alias types for a given base type is allowed, as shown here: + +# [.NET](#tab/net) + +[!code-csharp[](../../samples/Unions.cs#MixedAliasTypesNET)] + +# [.NET Standard](#tab/netfx) + +[!code-csharp[](../../samples/Unions.cs#MixedAliasTypesNETFX)] + +--- + +Following is an example of string alias inferrence: + +# [.NET](#tab/net) + +[!code-csharp[](../../samples/Unions.cs#InferredAliasTypesNET)] + +# [.NET Standard](#tab/netfx) + +[!code-csharp[](../../samples/Unions.cs#InferredAliasTypesNETFX)] + +--- + +Note that while inferrence is the simplest syntax, it results in the serialized schema including the full name of the type, which can make the serialized form more fragile in the face of refactoring changes. +It can also result in a poorer experience if the data is exchanged with non-.NET programs. + +### Nested sub-types + +Suppose you had the following type hierarchy: + +Animal <- Horse <- Quarterback + +The `Animal` class _must_ have the whole set of transitive derived types listed as known sub-types directly on itself. +It will not do for `Animal` to merely mention `Horse` and for `Horse` to listed `Quarterback` as a sub-type, as this is not currently supported. + ### Generic sub-types Sub-types may be generic types, but they must be *closed* generic types (i.e. all the generic type arguments must be specified). @@ -98,3 +155,25 @@ For example: [!code-csharp[](../../samples/Unions.cs#ClosedGenericSubTypesNETFX)] --- + +### Runtime subtype registration + +Static registration via attributes is not always possible. +For instance, you may want to serialize types from a third-party library that you cannot modify. +Or you may have an extensible plugin system where new types are added at runtime. +Or most simply, the derived types may not be declared in the same assembly as the base type. + +In such cases, runtime registration of subtypes is possible to allow you to run any custom logic you may require to discover and register subtypes. +Your code is still responsible to ensure unique aliases are assigned to each subtype. + +Consider the following example where a type hierarchy is registered without using the attribute approach: + +# [.NET](#tab/net) + +[!code-csharp[](../../samples/Unions.cs#RuntimeSubTypesNET)] + +# [.NET Standard](#tab/netfx) + +[!code-csharp[](../../samples/Unions.cs#RuntimeSubTypesNETFX)] + +--- diff --git a/samples/Unions.cs b/samples/Unions.cs index 7796df63..541b38db 100644 --- a/samples/Unions.cs +++ b/samples/Unions.cs @@ -29,9 +29,9 @@ public partial class Dog : Animal { } #endregion #else #region FarmAnimalsNETFX - [KnownSubType(1, typeof(Cow))] - [KnownSubType(2, typeof(Horse))] - [KnownSubType(3, typeof(Dog))] + [KnownSubType(typeof(Cow), 1)] + [KnownSubType(typeof(Horse), 2)] + [KnownSubType(typeof(Dog), 3)] public class Animal { public string? Name { get; set; } @@ -66,8 +66,8 @@ public partial class Thoroughbred : Horse { } #endregion #else #region HorseBreedsNETFX - [KnownSubType(1, typeof(QuarterHorse))] - [KnownSubType(2, typeof(Thoroughbred))] + [KnownSubType(typeof(QuarterHorse), 1)] + [KnownSubType(typeof(Thoroughbred), 2)] public partial class Horse : Animal { } [GenerateShape] @@ -105,9 +105,9 @@ class ClovenHoof { } #endregion #else #region ClosedGenericSubTypesNETFX - [KnownSubType(1, typeof(Horse))] - [KnownSubType(2, typeof(Cow))] - [KnownSubType(3, typeof(Cow))] + [KnownSubType(typeof(Horse), 1)] + [KnownSubType(typeof(Cow), 2)] + [KnownSubType(typeof(Cow), 3)] class Animal { public string? Name { get; set; } @@ -128,3 +128,173 @@ class ClovenHoof { } #endregion #endif } + +namespace StringAliasTypes +{ +#if NET + #region StringAliasTypesNET + [GenerateShape] + [KnownSubType("Horse")] + [KnownSubType("Cow")] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#else + #region StringAliasTypesNETFX + [GenerateShape] + [KnownSubType(typeof(Horse), "Horse")] + [KnownSubType(typeof(Cow), "Cow")] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#endif +} + +namespace MixedAliasTypes +{ +#if NET + #region MixedAliasTypesNET + [GenerateShape] + [KnownSubType(1)] + [KnownSubType("Cow")] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#else + #region MixedAliasTypesNETFX + [GenerateShape] + [KnownSubType(typeof(Horse), 1)] + [KnownSubType(typeof(Cow), "Cow")] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#endif +} + +namespace InferredAliasTypes +{ +#if NET + #region InferredAliasTypesNET + [GenerateShape] + [KnownSubType] + [KnownSubType] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#else + #region InferredAliasTypesNETFX + [GenerateShape] + [KnownSubType(typeof(Horse))] + [KnownSubType(typeof(Cow))] + partial class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + #endregion +#endif +} + +namespace RuntimeSubTypes +{ +#if NET + #region RuntimeSubTypesNET + class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + + class SerializationConfigurator + { + internal void ConfigureAnimalsMapping(MessagePackSerializer serializer) + { + KnownSubTypeMapping mapping = new(); + mapping.Add(1); + mapping.Add(2); + + serializer.RegisterKnownSubTypes(mapping); + } + } + #endregion +#else + #region RuntimeSubTypesNETFX + class Animal + { + public string? Name { get; set; } + } + + [GenerateShape] + partial class Horse : Animal { } + + [GenerateShape] + partial class Cow : Animal { } + + [GenerateShape] + [GenerateShape] + partial class Witness; + + class SerializationConfigurator + { + internal void ConfigureAnimalsMapping(MessagePackSerializer serializer) + { + KnownSubTypeMapping mapping = new(); + mapping.Add(1, Witness.ShapeProvider); + mapping.Add(2, Witness.ShapeProvider); + + serializer.RegisterKnownSubTypes(mapping); + } + } + #endregion +#endif +} diff --git a/src/Nerdbank.MessagePack.Analyzers/AnalyzerUtilities.cs b/src/Nerdbank.MessagePack.Analyzers/AnalyzerUtilities.cs index 513cec4a..b2363e20 100644 --- a/src/Nerdbank.MessagePack.Analyzers/AnalyzerUtilities.cs +++ b/src/Nerdbank.MessagePack.Analyzers/AnalyzerUtilities.cs @@ -1,6 +1,7 @@ // Copyright (c) Andrew Arnott. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Text; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Nerdbank.MessagePack.Analyzers; @@ -136,5 +137,24 @@ public static IEnumerable GetAllMembers(this ITypeSymbol symbol) ? a[typeArgumentIndex].GetLocation() : null; + public static string GetFullName(this INamedTypeSymbol symbol) + { + var sb = new StringBuilder(); + + if (symbol.ContainingType is not null) + { + sb.Append(GetFullName(symbol.ContainingType)); + sb.Append('.'); + } + else if (!symbol.ContainingNamespace.IsGlobalNamespace) + { + sb.Append(symbol.ContainingNamespace.MetadataName); + sb.Append('.'); + } + + sb.Append(symbol.MetadataName); + return sb.ToString(); + } + internal static string GetHelpLink(string diagnosticId) => $"https://aarnott.github.io/Nerdbank.MessagePack/analyzers/{diagnosticId}.html"; } diff --git a/src/Nerdbank.MessagePack.Analyzers/KnownSubTypeAnalyzers.cs b/src/Nerdbank.MessagePack.Analyzers/KnownSubTypeAnalyzers.cs index b91eeba9..016f7b2a 100644 --- a/src/Nerdbank.MessagePack.Analyzers/KnownSubTypeAnalyzers.cs +++ b/src/Nerdbank.MessagePack.Analyzers/KnownSubTypeAnalyzers.cs @@ -83,35 +83,69 @@ public override void Initialize(AnalysisContext context) { INamedTypeSymbol appliedSymbol = (INamedTypeSymbol)context.Symbol; AttributeData[] attributeDatas = context.Symbol.FindAttributes(referenceSymbols.KnownSubTypeAttribute).ToArray(); - Dictionary? typesByAlias = null; - Dictionary? aliasesByType = null; + Dictionary<(int?, string?), ITypeSymbol?>? typesByAlias = null; + Dictionary? aliasesByType = null; foreach (AttributeData att in attributeDatas) { - int? alias = att.ConstructorArguments is [{ Value: int a }, ..] ? a : null; + (int?, string?) alias = (null, null); + Location? aliasLocation = null; + int? aliasIndex = null; + if (att.ConstructorArguments is [{ Value: INamedTypeSymbol }, { Value: int }] or [{ Value: INamedTypeSymbol }, { Value: string }]) + { + aliasIndex = 1; + } + else if (att.ConstructorArguments is [{ Value: int or string }]) + { + aliasIndex = 0; + } + else + { + // The alias may have come from the FullName of the type specified. + if (att.AttributeClass?.TypeArguments.Length > 0) + { + if (att.AttributeClass.TypeArguments[0] is INamedTypeSymbol namedTypeArg) + { + alias = (null, namedTypeArg.GetFullName()); + } + } + else if (att.ConstructorArguments is [{ Value: INamedTypeSymbol subTypeArg2 }]) + { + alias = (null, subTypeArg2.GetFullName()); + } + } + + if (aliasIndex is not null) + { + alias = + att.ConstructorArguments[aliasIndex.Value].Value is int i ? (i, null) + : (null, (string)att.ConstructorArguments[aliasIndex.Value].Value!); + aliasLocation = GetArgumentLocation(aliasIndex.Value); + } + (ITypeSymbol? subType, Location? subTypeLocation) = att.AttributeClass?.TypeArguments.Length >= 1 ? (att.AttributeClass?.TypeArguments[0], GetTypeArgumentLocation(0)) : - att.ConstructorArguments.Length >= 2 ? ((ITypeSymbol?)att.ConstructorArguments[1].Value, GetArgumentLocation(1)) : - (null, null); + att.ConstructorArguments is [{ Value: INamedTypeSymbol subTypeArg }, ..] ? (subTypeArg, GetArgumentLocation(0)) : + (null, null); - if (alias is not null) + if (alias is not (null, null)) { typesByAlias ??= new(); - if (typesByAlias.TryGetValue(alias.Value, out ITypeSymbol? existingAssignment)) + if (aliasIndex >= 0 && typesByAlias.TryGetValue(alias, out ITypeSymbol? existingAssignment)) { context.ReportDiagnostic(Diagnostic.Create( NonUniqueAliasDescriptor, - GetArgumentLocation(0))); + aliasLocation)); } else { - typesByAlias.Add(alias.Value, subType); + typesByAlias.Add(alias, subType); } } if (subType is not null) { aliasesByType ??= new(SymbolEqualityComparer.Default); - if (aliasesByType.TryGetValue(subType, out int? existingAlias)) + if (aliasesByType.TryGetValue(subType, out (int?, string?) existingAlias)) { context.ReportDiagnostic(Diagnostic.Create( NonUniqueTypeDescriptor, diff --git a/src/Nerdbank.MessagePack/Converters/CommonRecords.cs b/src/Nerdbank.MessagePack/Converters/CommonRecords.cs index 02864923..b526fb13 100644 --- a/src/Nerdbank.MessagePack/Converters/CommonRecords.cs +++ b/src/Nerdbank.MessagePack/Converters/CommonRecords.cs @@ -135,12 +135,17 @@ internal record ArrayConstructorVisitorInputs(List<(string Name, internal record SubTypes { /// - /// Gets the converters to use to deserialize a subtype, keyed by their alias. + /// Gets the converters to use to deserialize a subtype, keyed by its integer alias. /// - internal required FrozenDictionary Deserializers { get; init; } + internal required FrozenDictionary DeserializersByIntAlias { get; init; } + + /// + /// Gets the converter to use to deserialize a subtype, keyed by its UTF-8 encoded string alias. + /// + internal required SpanDictionary DeserializersByStringAlias { get; init; } /// /// Gets the converter and alias to use for a subtype, keyed by their . /// - internal required FrozenDictionary Serializers { get; init; } + internal required FrozenDictionary Serializers { get; init; } } diff --git a/src/Nerdbank.MessagePack/Converters/ObjectMapConverter`1.cs b/src/Nerdbank.MessagePack/Converters/ObjectMapConverter`1.cs index 0732436f..00445d5c 100644 --- a/src/Nerdbank.MessagePack/Converters/ObjectMapConverter`1.cs +++ b/src/Nerdbank.MessagePack/Converters/ObjectMapConverter`1.cs @@ -154,7 +154,7 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, T? val int count = reader.ReadMapHeader(); for (int i = 0; i < count; i++) { - ReadOnlySpan propertyName = ReadStringSpan(ref reader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref reader); if (deserializable.Value.Readers.TryGetValue(propertyName, out DeserializableProperty propertyReader)) { propertyReader.Read(ref value, ref reader, context); @@ -223,7 +223,7 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, T? val int bufferedEntries = bufferedStructures / 2; for (int i = 0; i < bufferedEntries; i++) { - ReadOnlySpan propertyName = ReadStringSpan(ref syncReader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref syncReader); if (deserializable.Value.Readers.TryGetValue(propertyName, out DeserializableProperty propertyReader)) { propertyReader.Read(ref value, ref syncReader, context); @@ -243,7 +243,7 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, T? val if (bufferedStructures % 2 == 1) { // The property name has already been buffered. - ReadOnlySpan propertyName = ReadStringSpan(ref syncReader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref syncReader); if (deserializable.Value.Readers.TryGetValue(propertyName, out DeserializableProperty propertyReader) && propertyReader.PreferAsyncSerialization) { // The next property value is async, so turn in our sync reader and read it asynchronously. @@ -339,33 +339,6 @@ public override async ValueTask WriteAsync(MessagePackAsyncWriter writer, T? val return schema; } - /// - /// Reads a string as a contiguous span of UTF-8 encoded characters. - /// An array may be allocated if the string is not already contiguous in memory. - /// - /// The reader to use. - /// The span of UTF-8 encoded characters. - protected static ReadOnlySpan ReadStringSpan(scoped ref MessagePackReader reader) - { - if (!reader.TryReadStringSpan(out ReadOnlySpan result)) - { - ReadOnlySequence? sequence = reader.ReadStringSequence(); - if (sequence.HasValue) - { - if (sequence.Value.IsSingleSegment) - { - return sequence.Value.First.Span; - } - - return sequence.Value.ToArray(); - } - - return default; - } - - return result; - } - private Memory> GetPropertiesToSerialize(in T value, Memory> include) { return include[..this.GetPropertiesToSerialize(value, include.Span)]; diff --git a/src/Nerdbank.MessagePack/Converters/ObjectMapWithNonDefaultCtorConverter`2.cs b/src/Nerdbank.MessagePack/Converters/ObjectMapWithNonDefaultCtorConverter`2.cs index c55e4b1f..cc57636c 100644 --- a/src/Nerdbank.MessagePack/Converters/ObjectMapWithNonDefaultCtorConverter`2.cs +++ b/src/Nerdbank.MessagePack/Converters/ObjectMapWithNonDefaultCtorConverter`2.cs @@ -38,7 +38,7 @@ internal class ObjectMapWithNonDefaultCtorConverter propertyName = ReadStringSpan(ref reader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref reader); if (parameters.Readers.TryGetValue(propertyName, out DeserializableProperty deserializeArg)) { deserializeArg.Read(ref argState, ref reader, context); @@ -104,7 +104,7 @@ internal class ObjectMapWithNonDefaultCtorConverter propertyName = ReadStringSpan(ref syncReader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref syncReader); if (parameters.Readers.TryGetValue(propertyName, out DeserializableProperty propertyReader)) { propertyReader.Read(ref argState, ref syncReader, context); @@ -124,7 +124,7 @@ internal class ObjectMapWithNonDefaultCtorConverter propertyName = ReadStringSpan(ref syncReader); + ReadOnlySpan propertyName = StringEncoding.ReadStringSpan(ref syncReader); if (parameters.Readers.TryGetValue(propertyName, out DeserializableProperty propertyReader) && propertyReader.PreferAsyncSerialization) { // The next property value is async, so turn in our sync reader and read it asynchronously. diff --git a/src/Nerdbank.MessagePack/Converters/SubTypeUnionConverter`1.cs b/src/Nerdbank.MessagePack/Converters/SubTypeUnionConverter`1.cs index 142b9b38..ab6ae7bb 100644 --- a/src/Nerdbank.MessagePack/Converters/SubTypeUnionConverter`1.cs +++ b/src/Nerdbank.MessagePack/Converters/SubTypeUnionConverter`1.cs @@ -33,10 +33,22 @@ internal class SubTypeUnionConverter(SubTypes subTypes, MessagePackConver return baseConverter.Read(ref reader, context); } - int alias = reader.ReadInt32(); - if (!subTypes.Deserializers.TryGetValue(alias, out IMessagePackConverter? converter)) + IMessagePackConverter? converter; + if (reader.NextMessagePackType == MessagePackType.Integer) { - throw new MessagePackSerializationException($"Unknown alias {alias}."); + int alias = reader.ReadInt32(); + if (!subTypes.DeserializersByIntAlias.TryGetValue(alias, out converter)) + { + throw new MessagePackSerializationException($"Unknown alias {alias}."); + } + } + else + { + ReadOnlySpan alias = StringEncoding.ReadStringSpan(ref reader); + if (!subTypes.DeserializersByStringAlias.TryGetValue(alias, out converter)) + { + throw new MessagePackSerializationException($"Unknown alias \"{StringEncoding.UTF8.GetString(alias)}\"."); + } } return (TBase?)converter.Read(ref reader, context); @@ -61,9 +73,9 @@ public override void Write(ref MessagePackWriter writer, in TBase? value, Serial writer.WriteNil(); baseConverter.Write(ref writer, value, context); } - else if (subTypes.Serializers.TryGetValue(valueType, out (int Alias, IMessagePackConverter Converter, ITypeShape Shape) result)) + else if (subTypes.Serializers.TryGetValue(valueType, out (SubTypeAlias Alias, IMessagePackConverter Converter, ITypeShape Shape) result)) { - writer.Write(result.Alias); + writer.WriteRaw(result.Alias.MsgPackAlias.Span); result.Converter.Write(ref writer, value, context); } else @@ -78,7 +90,7 @@ public override void Write(ref MessagePackWriter writer, in TBase? value, Serial { JsonArray oneOfArray = [CreateOneOfElement(null, baseConverter.GetJsonSchema(context, typeShape) ?? CreateUndocumentedSchema(baseConverter.GetType()))]; - foreach ((int alias, _, ITypeShape shape) in subTypes.Serializers.Values) + foreach ((SubTypeAlias alias, _, ITypeShape shape) in subTypes.Serializers.Values) { oneOfArray.Add((JsonNode)CreateOneOfElement(alias, context.GetJsonSchema(shape))); } @@ -88,15 +100,27 @@ public override void Write(ref MessagePackWriter writer, in TBase? value, Serial ["oneOf"] = oneOfArray, }; - JsonObject CreateOneOfElement(int? alias, JsonObject schema) + JsonObject CreateOneOfElement(SubTypeAlias? alias, JsonObject schema) { JsonObject aliasSchema = new() { - ["type"] = alias is null ? "null" : "integer", + ["type"] = alias switch + { + null => "null", + { Type: SubTypeAlias.AliasType.Integer } => "integer", + { Type: SubTypeAlias.AliasType.String } => "string", + _ => throw new NotImplementedException(), + }, }; if (alias is not null) { - aliasSchema["enum"] = new JsonArray(alias.Value); + JsonNode enumValue = alias.Value.Type switch + { + SubTypeAlias.AliasType.String => (JsonNode)alias.Value.StringAlias, + SubTypeAlias.AliasType.Integer => (JsonNode)alias.Value.IntAlias, + _ => throw new NotImplementedException(), + }; + aliasSchema["enum"] = new JsonArray(enumValue); } return new() diff --git a/src/Nerdbank.MessagePack/IKnownSubTypeMapping.cs b/src/Nerdbank.MessagePack/IKnownSubTypeMapping.cs new file mode 100644 index 00000000..2e37e2c3 --- /dev/null +++ b/src/Nerdbank.MessagePack/IKnownSubTypeMapping.cs @@ -0,0 +1,23 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Frozen; + +namespace Nerdbank.MessagePack; + +/// +/// A non-generic accessor for +/// so that multiple such mappings can be stored in a collection and retrieved later. +/// +internal interface IKnownSubTypeMapping +{ + /// + /// Constructs a read-only dictionary of sub-types, keyed by their aliases. + /// + /// A collection of sub-types and aliases. + /// + /// It is not strictly required that the implementation guarantee that each type is unique, + /// because the requirement for uniqueness is enforced later when the known sub-type converter is initialized. + /// + FrozenDictionary CreateSubTypesMapping(); +} diff --git a/src/Nerdbank.MessagePack/KnownSubTypeAttribute.cs b/src/Nerdbank.MessagePack/KnownSubTypeAttribute.cs index 58b63f43..7707f506 100644 --- a/src/Nerdbank.MessagePack/KnownSubTypeAttribute.cs +++ b/src/Nerdbank.MessagePack/KnownSubTypeAttribute.cs @@ -5,6 +5,7 @@ #pragma warning disable SA1649 // File name should match first type name using System.Diagnostics; +using Microsoft; namespace Nerdbank.MessagePack; @@ -17,7 +18,6 @@ namespace Nerdbank.MessagePack; /// /// A class derived from the one to which this attribute is affixed. /// The class that serves as the shape provider for . -/// A value that identifies the subtype in the serialized data. Must be unique among all the attributes applied to the same class. /// /// /// A type with one or more of these attributes applied serializes to a different schema than the same type @@ -30,11 +30,38 @@ namespace Nerdbank.MessagePack; /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, Inherited = false, AllowMultiple = true)] [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] -#pragma warning disable CS0618 // Type or member is obsolete -public class KnownSubTypeAttribute(int alias) : KnownSubTypeAttribute(alias, typeof(TSubType)) -#pragma warning restore CS0618 // Type or member is obsolete +public class KnownSubTypeAttribute : KnownSubTypeAttribute where TShapeProvider : IShapeable { + /// + /// Initializes a new instance of the class. + /// + /// + public KnownSubTypeAttribute(int alias) +#pragma warning disable CS0618 // Type or member is obsolete + : base(typeof(TSubType), alias) +#pragma warning restore CS0618 // Type or member is obsolete + { + } + + /// + public KnownSubTypeAttribute(string alias) +#pragma warning disable CS0618 // Type or member is obsolete + : base(typeof(TSubType), alias) +#pragma warning restore CS0618 // Type or member is obsolete + { + } + + /// + /// Initializes a new instance of the class + /// that uses the of the as the alias. + /// + /// + public KnownSubTypeAttribute() + : this(TypeToAlias(typeof(TSubType))) + { + } + /// public override ITypeShape? Shape => TShapeProvider.GetShape(); @@ -47,9 +74,33 @@ public class KnownSubTypeAttribute(int alias) : KnownS /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, Inherited = false, AllowMultiple = true)] [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] -public class KnownSubTypeAttribute(int alias) : KnownSubTypeAttribute(alias) +public class KnownSubTypeAttribute : KnownSubTypeAttribute where TSubType : IShapeable { + /// + /// Initializes a new instance of the class. + /// + /// + public KnownSubTypeAttribute(int alias) + : base(alias) + { + } + + /// + public KnownSubTypeAttribute(string alias) + : base(alias) + { + } + + /// + /// Initializes a new instance of the class + /// that uses the of the as the alias. + /// + /// + public KnownSubTypeAttribute() + : this(TypeToAlias(typeof(TSubType))) + { + } } #endif @@ -80,21 +131,45 @@ public class KnownSubTypeAttribute : Attribute /// /// Initializes a new instance of the class. /// - /// A value that identifies the subtype in the serialized data. Must be unique among all the attributes applied to the same class. /// The derived-type that the represents. + /// A value that identifies the subtype in the serialized data. Must be unique among all the attributes applied to the same class. +#if NET + [Obsolete("Use the generic version of this attribute instead.")] +#endif + public KnownSubTypeAttribute(Type subType, int alias) + { + this.Alias = alias; + this.SubType = subType; + } + + /// #if NET [Obsolete("Use the generic version of this attribute instead.")] #endif - public KnownSubTypeAttribute(int alias, Type subType) + public KnownSubTypeAttribute(Type subType, string alias) { this.Alias = alias; this.SubType = subType; } /// - /// Gets a value that identifies the subtype in the serialized data. Must be unique among all the attributes applied to the same class. + /// Initializes a new instance of the class + /// that uses the of the as the alias. /// - public int Alias { get; } + /// + /// + /// Consider cross-platform compatibility when using this constructor, particularly when the serialized form may be exchanged with non-.NET programs + /// where the has no meaning. + /// +#if NET + [Obsolete("Use the generic version of this attribute instead.")] +#endif + public KnownSubTypeAttribute(Type subType) + { + Requires.NotNull(subType); + this.Alias = TypeToAlias(subType); + this.SubType = subType; + } /// /// Gets the sub-type. @@ -105,4 +180,17 @@ public KnownSubTypeAttribute(int alias, Type subType) /// Gets the shape that describes the subtype. /// public virtual ITypeShape? Shape => null; + + /// + /// Gets a value that identifies the subtype in the serialized data. Must be unique among all the attributes applied to the same class. + /// + internal SubTypeAlias Alias { get; } + + /// + /// Gets a string to serve as the alias for a given . + /// + /// The type. + /// The string alias for it. + /// Thrown if is . + internal static string TypeToAlias(Type type) => type.FullName ?? throw new ArgumentException("The type must have a name.", nameof(type)); } diff --git a/src/Nerdbank.MessagePack/KnownSubTypeMapping`1.cs b/src/Nerdbank.MessagePack/KnownSubTypeMapping`1.cs new file mode 100644 index 00000000..976602a3 --- /dev/null +++ b/src/Nerdbank.MessagePack/KnownSubTypeMapping`1.cs @@ -0,0 +1,69 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Frozen; +using Microsoft; + +namespace Nerdbank.MessagePack; + +/// +/// Describes a mapping between a base type and its known sub-types, along with the aliases that identify them. +/// +/// The base type or interface that all sub-types derive from or implement. +public class KnownSubTypeMapping : IKnownSubTypeMapping +{ + private readonly Dictionary map = new(); + private readonly HashSet addedTypes = new(); + + /// + /// Adds a known sub-type to the mapping. + /// + /// The sub-type. + /// The alias for the sub-type. + /// The shape of the sub-type. + /// Thrown when or the described by have already been added to this mapping. + public void Add(int alias, ITypeShape typeShape) + where TDerived : TBase + { + Requires.NotNull(typeShape); + this.map.Add(alias, typeShape); + if (!this.addedTypes.Add(typeof(TDerived))) + { + this.map.Remove(alias); + throw new ArgumentException($"The type {typeof(TDerived)} has already been added to the mapping.", nameof(alias)); + } + } + + /// + /// + /// + /// + public void Add(int alias, ITypeShapeProvider provider) + where TDerived : TBase + { + Requires.NotNull(provider); + + ITypeShape? shape = (ITypeShape?)provider.GetShape(typeof(TDerived)); + Requires.Argument(shape is not null, nameof(provider), "The provider did not provide a shape for the given type."); + this.Add(alias, shape); + } + +#if NET + /// + public void Add(int alias) + where TDerived : TBase, IShapeable => this.Add(alias, TDerived.GetShape()); + + /// + /// + /// + /// + /// The witness class that provides a type shape for . + public void Add(int alias) + where TDerived : TBase + where TProvider : IShapeable + => this.Add(alias, TProvider.GetShape()); +#endif + + /// + FrozenDictionary IKnownSubTypeMapping.CreateSubTypesMapping() => this.map.ToFrozenDictionary(); +} diff --git a/src/Nerdbank.MessagePack/MessagePackSerializer.cs b/src/Nerdbank.MessagePack/MessagePackSerializer.cs index 8df8fb92..f869eddf 100644 --- a/src/Nerdbank.MessagePack/MessagePackSerializer.cs +++ b/src/Nerdbank.MessagePack/MessagePackSerializer.cs @@ -4,6 +4,7 @@ #pragma warning disable RS0026 // optional parameter on a method with overloads using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; using System.Reflection; @@ -32,6 +33,8 @@ public partial record MessagePackSerializer private readonly ConcurrentDictionary userProvidedConverters = new(); + private readonly ConcurrentDictionary userProvidedKnownSubTypes = new(); + private bool configurationLocked; private MultiProviderTypeCache? cachedConverters; @@ -263,6 +266,33 @@ public void RegisterConverter(MessagePackConverter converter) : converter; } + /// + /// Registers a known sub-type mapping for a base type. + /// + /// + /// The mapping. + /// + /// + /// This method provides a runtime dynamic alternative to the otherwise simpler but static + /// , enabling scenarios such as sub-types that are not known at compile time. + /// + /// + /// This is also the only way to force the serialized schema to support sub-types in the future when + /// no sub-types are defined yet, such that they can be added later without a schema-breaking change. + /// + /// + /// A mapping provided for a given will completely replace any mapping from + /// attributes that may be applied to that same . + /// + /// + /// Thrown if serialization has already occurred. All calls to this method should be made before anything is serialized. + public void RegisterKnownSubTypes(KnownSubTypeMapping mapping) + { + Requires.NotNull(mapping); + this.VerifyConfigurationIsNotLocked(); + this.userProvidedKnownSubTypes[typeof(TBase)] = mapping; + } + /// /// Serializes a value. /// @@ -619,6 +649,24 @@ internal string GetSerializedPropertyName(string name, ICustomAttributeProvider? return this.PropertyNamingPolicy.ConvertName(name); } + /// + /// Gets the runtime registered sub-types for a given base type, if any. + /// + /// The base type. + /// If sub-types are registered, receives the mapping of those sub-types to their aliases. + /// if sub-types are registered; otherwise. + internal bool TryGetDynamicSubTypes(Type baseType, [NotNullWhen(true)] out IReadOnlyDictionary? subTypes) + { + if (this.userProvidedKnownSubTypes.TryGetValue(baseType, out IKnownSubTypeMapping? mapping)) + { + subTypes = mapping.CreateSubTypesMapping(); + return true; + } + + subTypes = null; + return false; + } + /// /// Creates a new serialization context that is ready to process a serialization job. /// diff --git a/src/Nerdbank.MessagePack/StandardVisitor.cs b/src/Nerdbank.MessagePack/StandardVisitor.cs index 43c1a41d..502738d5 100644 --- a/src/Nerdbank.MessagePack/StandardVisitor.cs +++ b/src/Nerdbank.MessagePack/StandardVisitor.cs @@ -521,27 +521,57 @@ protected IMessagePackConverter GetConverter(ITypeShape shape, object? state = n /// Thrown if has any that violates rules. private SubTypes? DiscoverUnionTypes(IObjectTypeShape objectShape) { - KnownSubTypeAttribute[]? unionAttributes = objectShape.AttributeProvider?.GetCustomAttributes(typeof(KnownSubTypeAttribute), false).Cast().ToArray(); - if (unionAttributes is null or { Length: 0 }) + IReadOnlyDictionary? mapping; + if (!this.owner.TryGetDynamicSubTypes(objectShape.Type, out mapping)) { - return null; + KnownSubTypeAttribute[]? unionAttributes = objectShape.AttributeProvider?.GetCustomAttributes(typeof(KnownSubTypeAttribute), false).Cast().ToArray(); + if (unionAttributes is null or { Length: 0 }) + { + return null; + } + + Dictionary mutableMapping = new(); + foreach (KnownSubTypeAttribute unionAttribute in unionAttributes) + { + ITypeShape subtypeShape = unionAttribute.Shape ?? objectShape.Provider.GetShapeOrThrow(unionAttribute.SubType); + Verify.Operation(objectShape.Type.IsAssignableFrom(subtypeShape.Type), $"The type {objectShape.Type.FullName} has a {KnownSubTypeAttribute.TypeName} that references non-derived {subtypeShape.Type.FullName}."); + Verify.Operation(mutableMapping.TryAdd(unionAttribute.Alias, subtypeShape), $"The type {objectShape.Type.FullName} has more than one {KnownSubTypeAttribute.TypeName} with a duplicate alias: {unionAttribute.Alias}."); + } + + mapping = mutableMapping; } - Dictionary deserializerData = new(); - Dictionary serializerData = new(); - foreach (KnownSubTypeAttribute unionAttribute in unionAttributes) + Dictionary deserializeByIntData = new(); + Dictionary, IMessagePackConverter> deserializeByUtf8Data = new(); + Dictionary serializerData = new(); + foreach (KeyValuePair pair in mapping) { - ITypeShape subtypeShape = unionAttribute.Shape ?? objectShape.Provider.GetShapeOrThrow(unionAttribute.SubType); - Verify.Operation(objectShape.Type.IsAssignableFrom(subtypeShape.Type), $"The type {objectShape.Type.FullName} has a {KnownSubTypeAttribute.TypeName} that references non-derived {subtypeShape.Type.FullName}."); + SubTypeAlias alias = pair.Key; + ITypeShape shape = pair.Value; + + // We don't want a reference-preserving converter here because that layer has already run + // by the time our subtype converter is invoked. + // And doubling up on it means values get serialized incorrectly. + IMessagePackConverter converter = this.GetConverter(shape).UnwrapReferencePreservation(); + switch (alias.Type) + { + case SubTypeAlias.AliasType.Integer: + deserializeByIntData.Add(alias.IntAlias, converter); + break; + case SubTypeAlias.AliasType.String: + deserializeByUtf8Data.Add(alias.Utf8Alias, converter); + break; + default: + throw new NotImplementedException("Unknown alias type."); + } - IMessagePackConverter converter = this.GetConverter(subtypeShape); - Verify.Operation(deserializerData.TryAdd(unionAttribute.Alias, converter), $"The type {objectShape.Type.FullName} has more than one {KnownSubTypeAttribute.TypeName} with a duplicate alias: {unionAttribute.Alias}."); - Verify.Operation(serializerData.TryAdd(subtypeShape.Type, (unionAttribute.Alias, converter, subtypeShape)), $"The type {objectShape.Type.FullName} has more than one subtype with a duplicate alias: {unionAttribute.Alias}."); + Verify.Operation(serializerData.TryAdd(shape.Type, (alias, converter, shape)), $"The type {objectShape.Type.FullName} has more than one subtype with a duplicate alias: {alias}."); } return new SubTypes { - Deserializers = deserializerData.ToFrozenDictionary(), + DeserializersByIntAlias = deserializeByIntData.ToFrozenDictionary(), + DeserializersByStringAlias = new SpanDictionary(deserializeByUtf8Data, ByteSpanEqualityComparer.Ordinal), Serializers = serializerData.ToFrozenDictionary(), }; } diff --git a/src/Nerdbank.MessagePack/StringEncoding.cs b/src/Nerdbank.MessagePack/StringEncoding.cs index 3a52fa68..a0e174fc 100644 --- a/src/Nerdbank.MessagePack/StringEncoding.cs +++ b/src/Nerdbank.MessagePack/StringEncoding.cs @@ -34,4 +34,31 @@ internal static void GetEncodedStringBytes(string value, out ReadOnlyMemory + /// Reads a string as a contiguous span of UTF-8 encoded characters. + /// An array may be allocated if the string is not already contiguous in memory. + /// + /// The reader to use. + /// The span of UTF-8 encoded characters. + internal static ReadOnlySpan ReadStringSpan(scoped ref MessagePackReader reader) + { + if (!reader.TryReadStringSpan(out ReadOnlySpan result)) + { + ReadOnlySequence? sequence = reader.ReadStringSequence(); + if (sequence.HasValue) + { + if (sequence.Value.IsSingleSegment) + { + return sequence.Value.First.Span; + } + + return sequence.Value.ToArray(); + } + + return default; + } + + return result; + } } diff --git a/src/Nerdbank.MessagePack/SubTypeAlias.cs b/src/Nerdbank.MessagePack/SubTypeAlias.cs new file mode 100644 index 00000000..12ef5632 --- /dev/null +++ b/src/Nerdbank.MessagePack/SubTypeAlias.cs @@ -0,0 +1,99 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft; + +namespace Nerdbank.MessagePack; + +/// +/// Acts as a type union between a and an , which are the allowed types for sub-type aliases. +/// +internal struct SubTypeAlias : IEquatable +{ + private string? stringAlias; + private ReadOnlyMemory utfAlias; + private ReadOnlyMemory msgpackAlias; + private int? intAlias; + + /// + internal SubTypeAlias(int alias) + { + this.intAlias = alias; + byte[] msgpack = new byte[5]; // maximum possible value can be encoded in this buffer. + Assumes.True(MessagePackPrimitives.TryWrite(msgpack, alias, out int bytesWritten)); + this.msgpackAlias = msgpack.AsMemory(0, bytesWritten); + } + + /// + /// Initializes a new instance of the struct. + /// + /// The alias. + internal SubTypeAlias(string alias) + { + this.stringAlias = alias; + StringEncoding.GetEncodedStringBytes(alias, out this.utfAlias, out this.msgpackAlias); + } + + /// + /// The types of values that are allowed for use as aliases. + /// + internal enum AliasType + { + /// + /// The struct is uninitialized. This constitutes an internal error. + /// + None, + + /// + /// The alias is an . + /// + Integer, + + /// + /// The alias is a . + /// + String, + } + + /// + /// Gets the type of this alias. + /// + public AliasType Type => this.stringAlias is not null ? AliasType.String : this.intAlias is not null ? AliasType.Integer : AliasType.None; + + /// + /// Gets the alias. + /// + /// Thrown if is not . + public string StringAlias => this.stringAlias ?? throw new InvalidOperationException(); + + /// + /// Gets the alias. + /// + /// Thrown if is not . + public int IntAlias => this.intAlias ?? throw new InvalidOperationException(); + + /// + /// Gets the msgpack encoding of the alias. + /// + public ReadOnlyMemory MsgPackAlias => this.msgpackAlias; + + /// + /// Gets the UTF-8 encoding of the alias. + /// + /// Thrown if is not . + public ReadOnlyMemory Utf8Alias => this.stringAlias is not null ? this.utfAlias : throw new InvalidOperationException(); + + public static implicit operator SubTypeAlias(string alias) => new(alias); + + public static implicit operator SubTypeAlias(int alias) => new(alias); + + /// + public bool Equals(SubTypeAlias other) => this.stringAlias == other.stringAlias && this.intAlias == other.intAlias; + + /// + public override bool Equals([NotNullWhen(true)] object? obj) => obj is SubTypeAlias other && this.Equals(other); + + /// + public override int GetHashCode() => this.stringAlias?.GetHashCode() ?? this.intAlias?.GetHashCode() ?? 0; +} diff --git a/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt b/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt index 2fccc277..dd8c7fca 100644 --- a/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt +++ b/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt @@ -75,13 +75,24 @@ Nerdbank.MessagePack.KeyAttribute Nerdbank.MessagePack.KeyAttribute.Index.get -> int Nerdbank.MessagePack.KeyAttribute.KeyAttribute(int index) -> void Nerdbank.MessagePack.KnownSubTypeAttribute -Nerdbank.MessagePack.KnownSubTypeAttribute.Alias.get -> int -Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(int alias, System.Type! subType) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType, int alias) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType, string! alias) -> void Nerdbank.MessagePack.KnownSubTypeAttribute.SubType.get -> System.Type! Nerdbank.MessagePack.KnownSubTypeAttribute +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute() -> void Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(int alias) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(string! alias) -> void Nerdbank.MessagePack.KnownSubTypeAttribute +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute() -> void Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(int alias) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(string! alias) -> void +Nerdbank.MessagePack.KnownSubTypeMapping +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias, PolyType.Abstractions.ITypeShape! typeShape) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias, PolyType.ITypeShapeProvider! provider) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.KnownSubTypeMapping() -> void Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode.ObjectReference.get -> sbyte Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode.ObjectReference.init -> void @@ -220,6 +231,7 @@ Nerdbank.MessagePack.MessagePackSerializer.PreserveReferences.init -> void Nerdbank.MessagePack.MessagePackSerializer.PropertyNamingPolicy.get -> Nerdbank.MessagePack.MessagePackNamingPolicy? Nerdbank.MessagePack.MessagePackSerializer.PropertyNamingPolicy.init -> void Nerdbank.MessagePack.MessagePackSerializer.RegisterConverter(Nerdbank.MessagePack.MessagePackConverter! converter) -> void +Nerdbank.MessagePack.MessagePackSerializer.RegisterKnownSubTypes(Nerdbank.MessagePack.KnownSubTypeMapping! mapping) -> void Nerdbank.MessagePack.MessagePackSerializer.Serialize(in T? value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> byte[]! Nerdbank.MessagePack.MessagePackSerializer.Serialize(System.Buffers.IBufferWriter! writer, in T? value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void Nerdbank.MessagePack.MessagePackSerializer.Serialize(System.IO.Stream! stream, in T? value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void diff --git a/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt b/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt index e3761bd4..7cc9fdbe 100644 --- a/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt @@ -75,9 +75,14 @@ Nerdbank.MessagePack.KeyAttribute Nerdbank.MessagePack.KeyAttribute.Index.get -> int Nerdbank.MessagePack.KeyAttribute.KeyAttribute(int index) -> void Nerdbank.MessagePack.KnownSubTypeAttribute -Nerdbank.MessagePack.KnownSubTypeAttribute.Alias.get -> int -Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(int alias, System.Type! subType) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType, int alias) -> void +Nerdbank.MessagePack.KnownSubTypeAttribute.KnownSubTypeAttribute(System.Type! subType, string! alias) -> void Nerdbank.MessagePack.KnownSubTypeAttribute.SubType.get -> System.Type! +Nerdbank.MessagePack.KnownSubTypeMapping +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias, PolyType.Abstractions.ITypeShape! typeShape) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.Add(int alias, PolyType.ITypeShapeProvider! provider) -> void +Nerdbank.MessagePack.KnownSubTypeMapping.KnownSubTypeMapping() -> void Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode.ObjectReference.get -> sbyte Nerdbank.MessagePack.LibraryReservedMessagePackExtensionTypeCode.ObjectReference.init -> void @@ -202,6 +207,7 @@ Nerdbank.MessagePack.MessagePackSerializer.PreserveReferences.init -> void Nerdbank.MessagePack.MessagePackSerializer.PropertyNamingPolicy.get -> Nerdbank.MessagePack.MessagePackNamingPolicy? Nerdbank.MessagePack.MessagePackSerializer.PropertyNamingPolicy.init -> void Nerdbank.MessagePack.MessagePackSerializer.RegisterConverter(Nerdbank.MessagePack.MessagePackConverter! converter) -> void +Nerdbank.MessagePack.MessagePackSerializer.RegisterKnownSubTypes(Nerdbank.MessagePack.KnownSubTypeMapping! mapping) -> void Nerdbank.MessagePack.MessagePackSerializer.Serialize(in T? value, PolyType.Abstractions.ITypeShape! shape, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> byte[]! Nerdbank.MessagePack.MessagePackSerializer.Serialize(in T? value, PolyType.ITypeShapeProvider! provider, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> byte[]! Nerdbank.MessagePack.MessagePackSerializer.Serialize(ref Nerdbank.MessagePack.MessagePackWriter writer, in T? value, PolyType.Abstractions.ITypeShape! shape, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void diff --git a/test/Nerdbank.MessagePack.Analyzers.Tests/KnownSubTypeAnalyzersTests.cs b/test/Nerdbank.MessagePack.Analyzers.Tests/KnownSubTypeAnalyzersTests.cs index 44708391..4ad203ca 100644 --- a/test/Nerdbank.MessagePack.Analyzers.Tests/KnownSubTypeAnalyzersTests.cs +++ b/test/Nerdbank.MessagePack.Analyzers.Tests/KnownSubTypeAnalyzersTests.cs @@ -26,7 +26,7 @@ public class DerivedType : IMyType, PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType))] + [KnownSubType(typeof(DerivedType), 1)] public interface IMyType { } @@ -40,7 +40,7 @@ public class DerivedType : IMyType } [Fact] - public async Task NoIssues_Subclass() + public async Task NoIssues_Subclass_Int() { #if NET string source = /* lang=c#-test */ """ @@ -60,7 +60,123 @@ public class DerivedType : MyType, PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType))] + [KnownSubType(typeof(DerivedType), 1)] + public class MyType + { + } + + public class DerivedType : MyType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoIssues_Subclass_Mixed() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(1)] + [KnownSubType("A")] + public class MyType + { + } + + public class DerivedType1 : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedTypeA : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(typeof(DerivedType1), 1)] + [KnownSubType(typeof(DerivedTypeA), "A")] + public class MyType + { + } + + public class DerivedType1 : MyType + { + } + + public class DerivedTypeA : MyType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoIssues_Subclass_String() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType("A")] + public class MyType + { + } + + public class DerivedType : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(typeof(DerivedType), "A")] + public class MyType + { + } + + public class DerivedType : MyType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoIssues_Subclass_Implied() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType] + public class MyType + { + } + + public class DerivedType : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(typeof(DerivedType))] public class MyType { } @@ -95,7 +211,42 @@ public class NonDerivedType : PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, {|NBMsgPack010:typeof(NonDerivedType)|})] + [KnownSubType({|NBMsgPack010:typeof(NonDerivedType)|}, 1)] + public class MyType + { + } + + public class NonDerivedType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NonDerivedType_ImpliedAlias() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType<{|NBMsgPack010:NonDerivedType|}>] + public class MyType + { + } + + public class NonDerivedType : PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType({|NBMsgPack010:typeof(NonDerivedType)|})] public class MyType { } @@ -140,7 +291,7 @@ public class DerivedType2 : MyType2, PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType1))] + [KnownSubType(typeof(DerivedType1), 1)] public class MyType { } @@ -149,7 +300,7 @@ public class DerivedType1 : MyType { } - [KnownSubType(1, typeof(DerivedType2))] + [KnownSubType(typeof(DerivedType2), 1)] public class MyType2 { } @@ -164,7 +315,7 @@ public class DerivedType2 : MyType2 } [Fact] - public async Task NonUniqueAlias() + public async Task NonUniqueAlias_Int() { #if NET string source = /* lang=c#-test */ """ @@ -190,8 +341,170 @@ public class DerivedType2 : MyType, PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType1))] - [KnownSubType({|NBMsgPack011:1|}, typeof(DerivedType2))] + [KnownSubType(typeof(DerivedType1), 1)] + [KnownSubType(typeof(DerivedType2), {|NBMsgPack011:1|})] + public class MyType + { + } + + public class DerivedType1 : MyType + { + } + + public class DerivedType2 : MyType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NonUniqueAlias_String() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType("A")] + [KnownSubType({|NBMsgPack011:"A"|})] + public class MyType + { + } + + public class DerivedType1 : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedType2 : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(typeof(DerivedType1), "A")] + [KnownSubType(typeof(DerivedType2), {|NBMsgPack011:"A"|})] + public class MyType + { + } + + public class DerivedType1 : MyType + { + } + + public class DerivedType2 : MyType + { + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NonUniqueAlias_StringAndImplied() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + namespace A; + + class B + { + [KnownSubType] + [KnownSubType({|NBMsgPack011:"A.B.DerivedType1"|})] + public class MyType + { + } + + public class DerivedType1 : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedType2 : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + namespace A; + + class B + { + [KnownSubType(typeof(DerivedType1))] + [KnownSubType(typeof(DerivedType2), {|NBMsgPack011:"A.B.DerivedType1"|})] + public class MyType + { + } + + public class DerivedType1 : MyType + { + } + + public class DerivedType2 : MyType + { + } + } + """; +#endif + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NonUniqueAlias_Mixed() + { +#if NET + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType("A")] + [KnownSubType({|NBMsgPack011:"A"|})] + [KnownSubType(1)] + [KnownSubType({|NBMsgPack011:1|})] + public class MyType + { + } + + public class DerivedTypeA : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedTypeB : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedTypeC : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + + public class DerivedTypeD : MyType, PolyType.IShapeable + { + static PolyType.Abstractions.ITypeShape PolyType.IShapeable.GetShape() => throw new System.NotImplementedException(); + } + """; +#else + string source = /* lang=c#-test */ """ + using Nerdbank.MessagePack; + + [KnownSubType(typeof(DerivedType1), "A")] + [KnownSubType(typeof(DerivedType2), {|NBMsgPack011:"A"|})] + [KnownSubType(typeof(DerivedType3), 1)] + [KnownSubType(typeof(DerivedType4), {|NBMsgPack011:1|})] public class MyType { } @@ -203,6 +516,14 @@ public class DerivedType1 : MyType public class DerivedType2 : MyType { } + + public class DerivedType3 : MyType + { + } + + public class DerivedType4 : MyType + { + } """; #endif @@ -231,8 +552,8 @@ public class DerivedType1 : MyType, PolyType.IShapeable string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType1))] - [KnownSubType(2, {|NBMsgPack012:typeof(DerivedType1)|})] + [KnownSubType(typeof(DerivedType1), 1)] + [KnownSubType({|NBMsgPack012:typeof(DerivedType1)|}, 2)] public class MyType { } @@ -273,8 +594,8 @@ internal class Witness : PolyType.IShapeable>, PolyType.IShapea string source = /* lang=c#-test */ """ using Nerdbank.MessagePack; - [KnownSubType(1, typeof(DerivedType))] - [KnownSubType(2, typeof(DerivedType))] + [KnownSubType(typeof(DerivedType), 1)] + [KnownSubType(typeof(DerivedType), 2)] public class MyType { } diff --git a/test/Nerdbank.MessagePack.Tests/KnownSubTypeMappingTests.cs b/test/Nerdbank.MessagePack.Tests/KnownSubTypeMappingTests.cs new file mode 100644 index 00000000..5f8d34b6 --- /dev/null +++ b/test/Nerdbank.MessagePack.Tests/KnownSubTypeMappingTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +public partial class KnownSubTypeMappingTests(ITestOutputHelper logger) +{ + [Fact] + public void NonUniqueAliasesRejected() + { + KnownSubTypeMapping mapping = new(); +#if NET + mapping.Add(1); + ArgumentException ex = Assert.Throws(() => mapping.Add(1)); +#else + mapping.Add(1, Witness.ShapeProvider); + ArgumentException ex = Assert.Throws(() => mapping.Add(1, Witness.ShapeProvider)); +#endif + logger.WriteLine(ex.Message); + } + + [Fact] + public void NonUniqueTypesRejected() + { + KnownSubTypeMapping mapping = new(); +#if NET + mapping.Add(1); + ArgumentException ex = Assert.Throws(() => mapping.Add(2)); +#else + mapping.Add(1, Witness.ShapeProvider); + ArgumentException ex = Assert.Throws(() => mapping.Add(2, Witness.ShapeProvider)); +#endif + logger.WriteLine(ex.Message); + } + + [Fact] + public void NonUniquePairsRejected() + { + KnownSubTypeMapping mapping = new(); +#if NET + mapping.Add(1); + ArgumentException ex = Assert.Throws(() => mapping.Add(1)); +#else + mapping.Add(1, Witness.ShapeProvider); + ArgumentException ex = Assert.Throws(() => mapping.Add(1, Witness.ShapeProvider)); +#endif + logger.WriteLine(ex.Message); + } + + [GenerateShape] + internal partial class MyBase; + + [GenerateShape] + internal partial class MyDerivedA : MyBase; + + [GenerateShape] + internal partial class MyDerivedB : MyBase; + + [GenerateShape] + [GenerateShape] + [GenerateShape] + private partial class Witness; +} diff --git a/test/Nerdbank.MessagePack.Tests/KnownSubTypeTests.cs b/test/Nerdbank.MessagePack.Tests/KnownSubTypeTests.cs index 2b8ee3ff..ccb67b9b 100644 --- a/test/Nerdbank.MessagePack.Tests/KnownSubTypeTests.cs +++ b/test/Nerdbank.MessagePack.Tests/KnownSubTypeTests.cs @@ -92,6 +92,104 @@ public void UnknownDerivedType() this.Logger.WriteLine(ex.Message); } + [Fact] + public void MixedAliasTypes() + { + ReadOnlySequence msgpack = this.AssertRoundtrip(new MixedAliasDerivedA()); + MessagePackReader reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal("A", reader.ReadString()); + + msgpack = this.AssertRoundtrip(new MixedAliasDerived1()); + reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(1, reader.ReadInt32()); + } + + [Fact] + public void ImpliedAlias() + { + ReadOnlySequence msgpack = this.AssertRoundtrip(new ImpliedAliasDerived()); + MessagePackReader reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(typeof(ImpliedAliasDerived).FullName, reader.ReadString()); + } + + [Fact] + public void RecursiveSubTypes() + { + MessagePackSerializationException ex = Assert.Throws(() => this.Serializer.Serialize(new RecursiveDerivedDerived())); + this.Logger.WriteLine(ex.Message); + +#if false + // If it were to work, this is how we expect it to work: + ReadOnlySequence msgpack = this.AssertRoundtrip(new RecursiveDerivedDerived()); + MessagePackReader reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(1, reader.ReadInt32()); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(13, reader.ReadInt32()); +#endif + } + + [Fact] + public void RuntimeRegistration() + { + KnownSubTypeMapping mapping = new(); +#if NET + mapping.Add(1); + mapping.Add(2); +#else + mapping.Add(1, Witness.ShapeProvider); + mapping.Add(2, Witness.ShapeProvider); +#endif + this.Serializer.RegisterKnownSubTypes(mapping); + + this.AssertRoundtrip(new DynamicallyRegisteredBase()); + this.AssertRoundtrip(new DynamicallyRegisteredDerivedA()); + this.AssertRoundtrip(new DynamicallyRegisteredDerivedB()); + } + + [Fact] + public void RuntimeRegistration_OverridesStatic() + { + KnownSubTypeMapping mapping = new(); + mapping.Add(1, Witness.ShapeProvider); + this.Serializer.RegisterKnownSubTypes(mapping); + + // Verify that the base type has just one header. + ReadOnlySequence msgpack = this.AssertRoundtrip(new BaseClass { BaseClassProperty = 5 }); + MessagePackReader reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + reader.ReadNil(); + Assert.Equal(1, reader.ReadMapHeader()); + + // Verify that the header type value is the runtime-specified 1 instead of the static 3. + msgpack = this.AssertRoundtrip(new DerivedB(13)); + reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(1, reader.ReadInt32()); + + // Verify that statically set subtypes are not recognized if no runtime equivalents are registered. + MessagePackSerializationException ex = Assert.Throws(() => this.Roundtrip(new DerivedA())); + this.Logger.WriteLine(ex.Message); + } + + /// + /// Verify that an empty mapping is allowed and produces the schema that allows for sub-types to be added in the future. + /// + [Fact] + public void RuntimeRegistration_EmptyMapping() + { + KnownSubTypeMapping mapping = new(); + this.Serializer.RegisterKnownSubTypes(mapping); + ReadOnlySequence msgpack = this.AssertRoundtrip(new DynamicallyRegisteredBase()); + MessagePackReader reader = new(msgpack); + Assert.Equal(2, reader.ReadArrayHeader()); + reader.ReadNil(); + Assert.Equal(0, reader.ReadMapHeader()); + } + [GenerateShape>] internal partial class Witness; @@ -103,11 +201,11 @@ internal partial class Witness; [KnownSubType(4)] [KnownSubType, Witness>(5)] #else - [KnownSubType(1, typeof(DerivedA))] - [KnownSubType(2, typeof(DerivedAA))] - [KnownSubType(3, typeof(DerivedB))] - [KnownSubType(4, typeof(EnumerableDerived))] - [KnownSubType(5, typeof(DerivedGeneric))] + [KnownSubType(typeof(DerivedA), 1)] + [KnownSubType(typeof(DerivedAA), 2)] + [KnownSubType(typeof(DerivedB), 3)] + [KnownSubType(typeof(EnumerableDerived), 4)] + [KnownSubType(typeof(DerivedGeneric), 5)] #endif public partial record BaseClass { @@ -144,4 +242,59 @@ public partial record DerivedGeneric(T Value) : BaseClass [GenerateShape] public partial record UnknownDerived : BaseClass; + + [GenerateShape] +#if NET + [KnownSubType("A")] + [KnownSubType(1)] +#else + [KnownSubType(typeof(MixedAliasDerivedA), "A")] + [KnownSubType(typeof(MixedAliasDerived1), 1)] +#endif + public partial record MixedAliasBase; + + [GenerateShape] + public partial record MixedAliasDerivedA : MixedAliasBase; + + [GenerateShape] + public partial record MixedAliasDerived1 : MixedAliasBase; + + [GenerateShape] +#if NET + [KnownSubType] +#else + [KnownSubType(typeof(ImpliedAliasDerived))] +#endif + public partial record ImpliedAliasBase; + + [GenerateShape] + public partial record ImpliedAliasDerived : ImpliedAliasBase; + + [GenerateShape] + public partial record DynamicallyRegisteredBase; + + [GenerateShape] + public partial record DynamicallyRegisteredDerivedA : DynamicallyRegisteredBase; + + [GenerateShape] + public partial record DynamicallyRegisteredDerivedB : DynamicallyRegisteredBase; + + [GenerateShape] +#if NET + [KnownSubType(1)] +#else + [KnownSubType(typeof(RecursiveDerived), 1)] +#endif + public partial record RecursiveBase; + + [GenerateShape] +#if NET + [KnownSubType(13)] +#else + [KnownSubType(typeof(RecursiveDerivedDerived), 13)] +#endif + public partial record RecursiveDerived : RecursiveBase; + + [GenerateShape] + public partial record RecursiveDerivedDerived : RecursiveDerived; } diff --git a/test/Nerdbank.MessagePack.Tests/ReferencePreservationTests.cs b/test/Nerdbank.MessagePack.Tests/ReferencePreservationTests.cs index 8556d114..3b47668a 100644 --- a/test/Nerdbank.MessagePack.Tests/ReferencePreservationTests.cs +++ b/test/Nerdbank.MessagePack.Tests/ReferencePreservationTests.cs @@ -182,6 +182,42 @@ public void CustomExtensionTypeCode() Assert.Same(deserializedRoot.Value1, deserializedRoot.Value2); } + [Fact] + public void KnownSubTypes_StaticRegistration() + { + BaseRecord baseInstance = new BaseRecord(); + BaseRecord derivedInstance = new DerivedRecordA(); + BaseRecord[] array = [baseInstance, baseInstance, derivedInstance, derivedInstance]; + + BaseRecord[]? deserialized = this.Roundtrip(array); + + Assert.NotNull(deserialized); + Assert.IsType(deserialized[0]); + Assert.IsType(deserialized[2]); + Assert.Same(deserialized[0], deserialized[1]); + Assert.Same(deserialized[2], deserialized[3]); + } + + [Fact] + public void KnownSubTypes_DynamicRegistration() + { + KnownSubTypeMapping mapping = new(); + mapping.Add(1, Witness.ShapeProvider); + this.Serializer.RegisterKnownSubTypes(mapping); + + BaseRecord baseInstance = new BaseRecord(); + BaseRecord derivedInstance = new DerivedRecordB(); + BaseRecord[] array = [baseInstance, baseInstance, derivedInstance, derivedInstance]; + + BaseRecord[]? deserialized = this.Roundtrip(array); + + Assert.NotNull(deserialized); + Assert.IsType(deserialized[0]); + Assert.IsType(deserialized[2]); + Assert.Same(deserialized[0], deserialized[1]); + Assert.Same(deserialized[2], deserialized[3]); + } + [GenerateShape] public partial record RecordWithStrings { @@ -200,6 +236,20 @@ public partial record RecordWithObjects public object? Value3 { get; init; } } + [GenerateShape] +#if NET + [KnownSubType(1)] +#else + [KnownSubType(typeof(DerivedRecordA), 1)] +#endif + public partial record BaseRecord; + + [GenerateShape] + public partial record DerivedRecordA : BaseRecord; + + [GenerateShape] + public partial record DerivedRecordB : BaseRecord; + [GenerateShape] public partial record CustomType { @@ -285,5 +335,6 @@ public override void Write(ref MessagePackWriter writer, in CustomType2? value, [GenerateShape] [GenerateShape] [GenerateShape] + [GenerateShape] private partial class Witness; } diff --git a/test/Nerdbank.MessagePack.Tests/SchemaTests.cs b/test/Nerdbank.MessagePack.Tests/SchemaTests.cs index e5614489..3bb14171 100644 --- a/test/Nerdbank.MessagePack.Tests/SchemaTests.cs +++ b/test/Nerdbank.MessagePack.Tests/SchemaTests.cs @@ -339,7 +339,7 @@ internal partial class HasDateTime #if NET [KnownSubType(1)] #else - [KnownSubType(1, typeof(SubType))] + [KnownSubType(typeof(SubType), 1)] #endif internal partial class BaseType {