Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions docs/articles/serialization/source-generated-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ parameter has no covering `[AkkaField]` property. It then silently resets to its
deserialize. **AKKASG028** fires when an `[AkkaField]` property is not an accessible instance
property, such as a static property or a getter the generated code cannot reach.

The same reach rule applies to the message type itself. The generated serializer is a separate
class, so it cannot name a type declared `private` or `protected`, or nested inside one. A message
nested in another class must be `internal` or `public`, and so must every type that contains it.
**AKKASG045** reports a violation at the type declaration.

### Structs

A `struct`, including a `readonly record struct`, can carry `[AkkaSerializable]` and `[AkkaField]`
Expand Down Expand Up @@ -342,8 +347,8 @@ public sealed record ShippingAddress(

**Not supported:** `float`, `single`, plain `byte`, `sbyte`, `short`, `ushort`, `uint`, and `ulong`
as scalar field types. These are only meaningful as an enum's underlying type. Also not supported:
a mutable `HashSet<T>` or `ISet<T>`. Use `ImmutableHashSet<T>` instead. Also not supported yet: an
`object` element inside a collection, such as `List<object>` or `object[]`. Any of these fails
a mutable `HashSet<T>` or `ISet<T>`. Use `ImmutableHashSet<T>` instead. Also not supported: a
dictionary whose key type is `object`. Any of these fails
compilation with **AKKASG003**, naming the offending property and type.

## Unions
Expand Down Expand Up @@ -809,6 +814,12 @@ An earlier `dev` build marked this with an explicit `[AkkaEnvelopePayload]` attr
attribute was removed before the first 1.6 beta; the `object` type now carries the same meaning on
its own.

The same rule applies to a collection element. A `List<object>`, an `object[]`, an
`ImmutableList<object>`, or a `Dictionary<string, object>` value typed `object` or `object?` is an
envelope payload for each element, with the same runtime lookup. A `null` element round-trips as
`null` when the element type is `object?`. A dictionary *key* typed `object` is not supported
(**AKKASG003**), since a round-tripped payload has no stable identity to hash.

Reach for an envelope payload instead of a union when the set of possible payload types is not
closed at compile time. A generic delivery wrapper is one example: its payload could be *any*
message type in the application, including ones the wrapper's own serializer never sees. A payload
Expand Down Expand Up @@ -1095,16 +1106,17 @@ diagnostic was retired with it, and the id stays a permanent gap.
| AKKASG042 | Info | Closed-set expansion produced constructions | A `ManifestPrefix` registration expanded; the message names how many constructions it produced. |
| AKKASG043 | Error | Protocol is owned by an upstream serializer | A type declared here implements a protocol that a serializer in a referenced assembly already binds; that assembly can never see this type. See [Serializer Placement](#serializer-placement). |
| AKKASG044 | Warning | Serializer has no messages | A serializer has no messages here, in any referenced assembly, or in its own registrations -- often a sign the serializer is placed upstream of its messages. See [Serializer Placement](#serializer-placement). |
| AKKASG045 | Error | Message type is not accessible to generated code | An `[AkkaSerializable]` type, or a type that contains it, is `private`, `protected`, or `private protected`. The generated serializer cannot name it. Make it `internal` or `public`. |

## Limitations Today and Planned Changes

The generator reads what Roslyn can see at compile time: syntax trees for this compilation's own
declarations, and compiled metadata for a referenced assembly's. Two concrete consequences follow
today. First, a construction built through reflection over a type the generator never saw at
compile time fails only when it is first sent. This never happens at compile time. A generic
instantiation assembled dynamically at runtime is one example. Second, an `object` element inside a
collection, such as `List<object>` or `object[]`, is not yet a supported envelope boundary; it fails
compilation with **AKKASG003**.
instantiation assembled dynamically at runtime is one example. Second, a dictionary whose key type is
`object` fails compilation with **AKKASG003**. A round-tripped envelope payload has no stable
identity to use as a lookup key.

A top-level protocol implementor, a nested field type, a union member type, a marked union base's
members, and a closed generic's own open definition can all live in a referenced assembly today;
Expand All @@ -1114,9 +1126,6 @@ The following work is planned. None of it ships on `dev` today. It is tracked ag
[issue #8384](https://github.com/akkadotnet/akka.net/issues/8384) and
`openspec/changes/messagepack-sourcegen-validation/design.md`:

* **Support for an `object` element inside a collection.** A collection element typed `object`,
such as `List<object>` or `object[]`, is not yet a supported envelope boundary. It will follow
the same rule a property's own `object` type already follows today.
* **Serializer parts.** Splitting a serializer's identity from its encoders, so a serializer
declared upstream of its own messages can still dispatch them, composed explicitly at startup with
no scanning. A documented follow-up, not needed by the shapes this generator targets today.
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,16 @@ public sealed partial class AkkaSerializerGenerator
isEnabledByDefault: true);

// Same id/title/severity as MissingNestedSerializableDefinition. Used only when the nested
// type's assembly is not the one being compiled. This generator can only read a schema from
// the current compilation, so the type may already carry both attributes in its own assembly
// and still be unreadable from here. The message must name only the fixes that work today, and
// must not claim the type lacks the attributes.
// type's assembly is not the one being compiled and the generator found no [AkkaSerializable]
// schema it can read there. The generator reads a referenced type's schema from that assembly's
// metadata, so the usual cause is a missing [AkkaSerializable] (or [AkkaField]) in the other
// assembly. An inaccessible type or member is AKKASG039 instead.
private static readonly DiagnosticDescriptor MissingNestedSerializableDefinitionCrossAssembly = new(
"AKKASG007",
"Nested value object serialization definition is required",
"Property '{0}' on type '{1}' uses nested value object type '{2}', which is declared in assembly '{3}'. " +
"This generator cannot read a schema from a referenced assembly yet. " +
"Register [AkkaSerializerFormatter<{2}, TFormatter>] on '{4}', or declare the type in this assembly.",
"Property '{0}' on type '{1}' uses nested value object type '{2}', which is declared in assembly '{3}' but has no [AkkaSerializable] schema this generator can read there. " +
"Annotate it with [AkkaSerializable] and [AkkaField] properties in that assembly, " +
"register [AkkaSerializerFormatter<{2}, TFormatter>] on '{4}', or declare the type in this assembly.",
"Akka.Serialization.V2",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
Expand Down Expand Up @@ -180,13 +180,13 @@ public sealed partial class AkkaSerializerGenerator

// Same id/title/severity as UnionMemberNotSerializable. Used only when the member type's
// assembly is not the one being compiled. See MissingNestedSerializableDefinitionCrossAssembly
// for why the member may already carry [AkkaSerializable] and still be unreadable from here.
// for when the generator can and cannot read a schema from that assembly.
private static readonly DiagnosticDescriptor UnionMemberNotSerializableCrossAssembly = new(
"AKKASG015",
"Union member type is not serializable",
"Union member '{0}' on property '{1}' of type '{2}' is declared in assembly '{3}'. " +
"This generator cannot read a schema from a referenced assembly yet. " +
"Register [AkkaSerializerFormatter<{0}, TFormatter>] on '{4}', or declare the member in this assembly.",
"Union member '{0}' on property '{1}' of type '{2}' is declared in assembly '{3}' but has no [AkkaSerializable] schema this generator can read there. " +
"Annotate it with [AkkaSerializable] and [AkkaField] properties in that assembly, " +
"register [AkkaSerializerFormatter<{0}, TFormatter>] on '{4}', or declare the member in this assembly.",
"Akka.Serialization.V2",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
Expand Down Expand Up @@ -487,6 +487,20 @@ public sealed partial class AkkaSerializerGenerator
DiagnosticSeverity.Error,
isEnabledByDefault: true);

/// <summary>
/// A message type declared in this compilation that generated code cannot name: the type, or a
/// type that contains it, is private, protected, or private protected. The generated serializer
/// is a separate class, so without this diagnostic the build fails with CS0122 inside generated
/// code and no AKKASG id. Reported at the message type's declaration.
/// </summary>
private static readonly DiagnosticDescriptor MessageTypeNotAccessible = new(
"AKKASG045",
"Message type is not accessible to generated code",
"[AkkaSerializable] type '{0}' cannot be used by the generated serializer because {1}. Make the type, and every type that contains it, internal or public.",
"Akka.Serialization.V2",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

/// <summary>
/// Resolves a <see cref="DiagnosticKey"/> to the exact <see cref="DiagnosticDescriptor"/> field
/// above it names, and turns a <see cref="DiagnosticSpec"/> into a real <see cref="Diagnostic"/>
Expand Down Expand Up @@ -545,6 +559,7 @@ private static class DiagnosticRegistry
DiagnosticKey.ProtocolOwnedUpstream => ProtocolOwnedUpstream,
DiagnosticKey.SerializerHasNoMessages => SerializerHasNoMessages,
DiagnosticKey.DuplicateProtocolBindingCrossAssembly => DuplicateProtocolBindingCrossAssembly,
DiagnosticKey.MessageTypeNotAccessible => MessageTypeNotAccessible,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown DiagnosticKey: add a case mapping it to its DiagnosticDescriptor.")
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,33 @@ private static MessageInfo ExtractMessageCore(
isAbstract: symbol.IsAbstract,
isValueType: symbol.IsValueType,
foreignAssemblyName: GetForeignAssemblyName(symbol, knownTypes),
baseTypeNames: GetBaseTypeNames(symbol));
baseTypeNames: GetBaseTypeNames(symbol),
inaccessibleReason: DescribeInaccessibleDeclaration(symbol));
}

/// <summary>
/// Finds why generated code cannot name <paramref name="symbol"/>: it, or a type that contains
/// it, is declared private, protected, or private protected. The generated serializer is a
/// separate class that neither nests nor derives from the message, so it needs the whole
/// declaration chain to be public, internal, or protected internal. Returns an empty string when
/// the type is nameable. Only a type declared in this compilation can fail here: a
/// referenced-assembly type reaches ExtractMessageCore only after the AKKASG039 accessibility
/// check passed.
/// </summary>
private static string DescribeInaccessibleDeclaration(INamedTypeSymbol symbol)
{
for (INamedTypeSymbol? type = symbol; type != null; type = type.ContainingType)
{
if (!IsAccessibleFromGeneratedCode(type.DeclaredAccessibility))
{
var accessibility = type.DeclaredAccessibility == Accessibility.ProtectedAndInternal
? "private protected"
: type.DeclaredAccessibility.ToString().ToLowerInvariant();
return type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat) + " is " + accessibility;
}
}

return string.Empty;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,8 @@ public MessageInfo(
bool isAbstract = false,
bool isValueType = false,
string foreignAssemblyName = "",
ImmutableArray<string> baseTypeNames = default)
ImmutableArray<string> baseTypeNames = default,
string inaccessibleReason = "")
{
SimpleName = simpleName;
Key = key;
Expand All @@ -575,6 +576,7 @@ public MessageInfo(
IsValueType = isValueType;
ForeignAssemblyName = foreignAssemblyName;
BaseTypeNames = baseTypeNames.IsDefault ? ImmutableArray<string>.Empty : baseTypeNames;
InaccessibleReason = inaccessibleReason;
}

public string SimpleName { get; }
Expand Down Expand Up @@ -658,14 +660,24 @@ public MessageInfo(
/// </summary>
public ImmutableArray<string> BaseTypeNames { get; }

/// <summary>
/// Why generated code cannot name this message's type, for a type declared in THIS
/// compilation: the narrowest declaration, this type or a type that contains it, that is
/// private, protected, or private protected (for example <c>Outer.Inner is private</c>).
/// Generated serializer code lives in a separate class, so it would fail with CS0122 on
/// such a type; AKKASG045 reports it instead. Empty when the type is accessible. A
/// referenced-assembly type never sets this: AKKASG039 covers that case.
/// </summary>
public string InaccessibleReason { get; }

/// <summary>
/// Used by formatter resolution to swap in fields with a resolved <see cref="TypeMapping"/>.
/// <see cref="ConstructionPlan"/> is keyed by field NAME, not by <see cref="FieldInfo"/>
/// reference, so it stays valid across this substitution without needing to be rebuilt.
/// </summary>
public MessageInfo WithFields(ImmutableArray<FieldInfo> fields)
{
return new MessageInfo(SimpleName, Key, Manifest, fields, Protocols, AllowEmpty, InvalidFields, ConstructionPlan, IsGenericDefinition, DefinitionFullName, IsSealed, IsAbstract, IsValueType, ForeignAssemblyName, BaseTypeNames);
return new MessageInfo(SimpleName, Key, Manifest, fields, Protocols, AllowEmpty, InvalidFields, ConstructionPlan, IsGenericDefinition, DefinitionFullName, IsSealed, IsAbstract, IsValueType, ForeignAssemblyName, BaseTypeNames, InaccessibleReason);
}

public bool Equals(MessageInfo? other)
Expand All @@ -686,6 +698,7 @@ public bool Equals(MessageInfo? other)
&& IsAbstract == other.IsAbstract
&& IsValueType == other.IsValueType
&& string.Equals(ForeignAssemblyName, other.ForeignAssemblyName, StringComparison.Ordinal)
&& string.Equals(InaccessibleReason, other.InaccessibleReason, StringComparison.Ordinal)
&& ConstructionPlan.Equals(other.ConstructionPlan)
&& ValueEquality.SequenceEquals(Fields, other.Fields)
&& ValueEquality.SequenceEquals(Protocols, other.Protocols)
Expand All @@ -708,6 +721,7 @@ public override int GetHashCode()
hash = ValueEquality.Combine(hash, IsAbstract);
hash = ValueEquality.Combine(hash, IsValueType);
hash = ValueEquality.Combine(hash, ForeignAssemblyName);
hash = ValueEquality.Combine(hash, InaccessibleReason);
hash = ValueEquality.Combine(hash, ConstructionPlan.GetHashCode());
hash = ValueEquality.Combine(hash, Fields);
hash = ValueEquality.Combine(hash, Protocols);
Expand Down Expand Up @@ -1748,7 +1762,8 @@ internal enum DiagnosticKey
ClosedSetExpansionCount,
ProtocolOwnedUpstream,
SerializerHasNoMessages,
DuplicateProtocolBindingCrossAssembly
DuplicateProtocolBindingCrossAssembly,
MessageTypeNotAccessible
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,14 @@ private static bool ValidateMessages(
// construction's own registration attribute -- see MessageTypeLocationKey's doc comment.
var messageAt = MessageTypeLocationKey(serializer, message);

// A private (or protected) nested message cannot be named by the generated serializer
// class, so its emitted helpers would not compile (CS0122). Report it here instead.
if (message.InaccessibleReason.Length > 0)
{
diagnostics.Add(new DiagnosticSpec(DiagnosticKey.MessageTypeNotAccessible, messageAt, ToDisplayName(message.FullyQualifiedName), message.InaccessibleReason));
isValid = false;
}

if (message.Fields.Length == 0 && !message.AllowEmpty)
{
diagnostics.Add(new DiagnosticSpec(DiagnosticKey.MissingFields, messageAt, ToDisplayName(message.FullyQualifiedName)));
Expand Down
Loading
Loading