Repository: MessagePack-CSharp/MessagePack-CSharp

Issue:
Analyzers (and possibly AOT) adds requirement for Key/ctor ordering
I have tried MessagePack `3.0.134-beta` on my project. So far new analyzer detections force a very strict style that requires a lot of refactoring to get it going. This might be not the best code, but on large code base refactoring is always a matter of time you can spend for this. Analyzer for `2.5.172` was fine with that code.

Questions:
1. `Test1` - Why are we forced to start with `0` for constructor params? For me it is not always the case and I cannot change that.
2. `Test2`, `Test3` - Requirement to make `partial` class doesn't make sense. Code gen still has to use constructor.
3. `Test4` - Maybe analyzer should detect private member not annotated with attributes only when class is `partial`?

Common patterns detected:
```cs
[MessagePackObject]
public class Test1
{
    [Key(1)]
    //[Key("1")]// works fine with string keys
    public string Name { get; }

    [SerializationConstructor]
    public Test1(string name) => Name = name;
}

[MessagePackObject]
public class Test2
{
    private readonly string _name;

    [Key(0)]
    public string Name => _name;

    [SerializationConstructor]
    public Test2(string name) => _name = name;
}

// another way Test2 can implemented
[MessagePackObject]
public class Test3
{
    [Key(0)]
    public string Name { get; private set; }

    [SerializationConstructor]
    public Test3(string name) => Name = name;
}

[MessagePackObject]
public class Test4
{
    private int? _cachedValue;// not serialized by design

    [Key(0)]
    public string Name { get; set; }
}
```


Hints:
Thanks for trying out the beta and sharing your feedback. This is great to hear as we're closing down v3 and we want to identify adoption blockers.

> Test1 - Why are we forced to start with 0 for constructor params? For me it is not always the case and I cannot change that.

I don't recall intentionally changing this behavior, but as I'm surprised that what you had worked in the first place, I may have inadvertently made the change.
I thought for ordinal indexed members, the indexes had to match the constructor parameter positions. 
And apparently the diagnostic is simply in error, because on v3 it works as you have it written. But that's using the `DynamicObjectResolver`. Maybe the AOT source generation has more strict requirements. I'll have to look into that.

> Test2, Test3 - Requirement to make partial class doesn't make sense. Code gen still has to use constructor.

Quite right. And I'm pleased to say that I just recently made fixes that makes these tests work the way you expect without any warnings. (#1990)

> Test4 - Maybe analyzer should detect private member not annotated with attributes only when class is partial?

Also fixed by #1990. Not the way you propose, but the attributes are only required on public members by default. Once you start annotating non-public members, or use `[MessagePackObject(AllowPrivate=true)]`, diagnostics for the non-public members start asking for you to apply them to all private members.

So all up, not too bad. Just Test1 survives as an issue to be fixed. :)
Actually, Test3 still repros. I wasn't testing with the analyzers turned on, because evidently we have a bug where the analyzers aren't always brought in. But I agree we should get that fixed too.

Patch:
diff --git a/src/MessagePack.SourceGenerator/Analyzers/MsgPack00xMessagePackAnalyzer.cs b/src/MessagePack.SourceGenerator/Analyzers/MsgPack00xMessagePackAnalyzer.cs
index 0db4bbda0..f22b5f18f 100644
--- a/src/MessagePack.SourceGenerator/Analyzers/MsgPack00xMessagePackAnalyzer.cs
+++ b/src/MessagePack.SourceGenerator/Analyzers/MsgPack00xMessagePackAnalyzer.cs
@@ -166,7 +166,7 @@ public class MsgPack00xMessagePackAnalyzer : DiagnosticAnalyzer
         title: "Deserializing constructors",
         category: Category,
         messageFormat: "Deserializing constructor parameter count mismatch",
-        description: "Constructor parameter count must meet or exceed the number of serialized members or the highest key index.",
+        description: "The deserializing constructor parameter count must meet or exceed the number of serialized members.",
         defaultSeverity: DiagnosticSeverity.Error,
         isEnabledByDefault: true,
         helpLinkUri: AnalyzerUtilities.GetHelpLink(DeserializingConstructorId));
diff --git a/src/MessagePack.SourceGenerator/CodeAnalysis/TypeCollector.cs b/src/MessagePack.SourceGenerator/CodeAnalysis/TypeCollector.cs
index 4a84b7afa..80d24ad91 100644
--- a/src/MessagePack.SourceGenerator/CodeAnalysis/TypeCollector.cs
+++ b/src/MessagePack.SourceGenerator/CodeAnalysis/TypeCollector.cs
@@ -5,6 +5,7 @@
 #pragma warning disable SA1649 // File name should match first type name
 
 using System.Collections.Immutable;
+using System.ComponentModel;
 using System.Reflection;
 using System.Text.RegularExpressions;
 using Microsoft.CodeAnalysis;
@@ -682,6 +683,7 @@ private bool CheckValidMessagePackFormatterAttribute(AttributeData formatterAttr
     {
         var isClass = !formattedType.IsValueType;
         bool nestedFormatterRequired = false;
+        List<IPropertySymbol> nestedFormatterRequiredIfPropertyIsNotSetByDeserializingCtor = new();
 
         AttributeData? contractAttr = formattedType.GetAttributes().FirstOrDefault(x => x.AttributeClass.ApproximatelyEqual(this.typeReferences.MessagePackObjectAttribute));
 
@@ -875,7 +877,10 @@ private bool CheckValidMessagePackFormatterAttribute(AttributeData formatterAttr
                     nonPublicMembersAreSerialized |= (item.DeclaredAccessibility & Accessibility.Public) != Accessibility.Public;
 
                     nestedFormatterRequired |= item.GetMethod is not null && IsPartialTypeRequired(item.GetMethod.DeclaredAccessibility);
-                    nestedFormatterRequired |= item.SetMethod is not null && IsPartialTypeRequired(item.SetMethod.DeclaredAccessibility);
+                    if (item.SetMethod is not null && IsPartialTypeRequired(item.SetMethod.DeclaredAccessibility))
+                    {
+                        nestedFormatterRequiredIfPropertyIsNotSetByDeserializingCtor.Add(item);
+                    }
 
                     var intKey = key is { Value: int intKeyValue } ? intKeyValue : default(int?);
                     var stringKey = key is { Value: string stringKeyValue } ? stringKeyValue : default;
@@ -1062,6 +1067,9 @@ private bool CheckValidMessagePackFormatterAttribute(AttributeData formatterAttr
             nestedFormatterRequired |= IsPartialTypeRequired(ctor.DeclaredAccessibility);
 
             var constructorLookupDictionary = stringMembers.ToLookup(x => x.Value.Info.Name, x => x, StringComparer.OrdinalIgnoreCase);
+            IReadOnlyDictionary<int, (MemberSerializationInfo Info, ITypeSymbol TypeSymbol)> ctorParamIndexIntMembersDictionary = intMembers
+                .OrderBy(x => x.Key).Select((x, i) => (Key: x.Value, Index: i))
+                .ToDictionary(x => x.Index, x => x.Key);
             do
             {
                 constructorParameters.Clear();
@@ -1070,7 +1078,7 @@ private bool CheckValidMessagePackFormatterAttribute(AttributeData formatterAttr
                 {
                     if (isIntKey)
                     {
-                        if (intMembers.TryGetValue(ctorParamIndex, out (MemberSerializationInfo Info, ITypeSymbol TypeSymbol) member))
+                        if (ctorParamIndexIntMembersDictionary.TryGetValue(ctorParamIndex, out (MemberSerializationInfo Info, ITypeSymbol TypeSymbol) member))
                         {
                             if (this.compilation.ClassifyConversion(member.TypeSymbol, item.Type) is { IsImplicit: true } && member.Info.IsReadable)
                             {
@@ -1204,6 +1212,13 @@ private bool CheckValidMessagePackFormatterAttribute(AttributeData formatterAttr
             return null;
         }
 
+        // If any property had a private setter and does not appear in the deserializing constructor signature,
+        // we'll need a nested formatter.
+        foreach (IPropertySymbol property in nestedFormatterRequiredIfPropertyIsNotSetByDeserializingCtor)
+        {
+            nestedFormatterRequired |= !constructorParameters.Any(m => m.Name == property.Name);
+        }
+
         if (nestedFormatterRequired && nonPublicMembersAreSerialized)
         {
             // If the data type or any nesting types are not declared with partial, we cannot emit the formatter as a nested type within the data type


Test patch:
diff --git a/tests/MessagePack.SourceGenerator.ExecutionTests/DeserializingConstructorStartsWithIdx1.cs b/tests/MessagePack.SourceGenerator.ExecutionTests/DeserializingConstructorStartsWithIdx1.cs
new file mode 100644
index 000000000..d0f0857b9
--- /dev/null
+++ b/tests/MessagePack.SourceGenerator.ExecutionTests/DeserializingConstructorStartsWithIdx1.cs
@@ -0,0 +1,14 @@
+﻿// Copyright (c) All contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+[MessagePackObject]
+public class DeserializingConstructorStartsWithIdx1 : IEquatable<DeserializingConstructorStartsWithIdx1>
+{
+    [Key(1)] // 1 instead of 0. Works on v2. Test case from https://github.com/MessagePack-CSharp/MessagePack-CSharp/issues/1993
+    public string Name { get; }
+
+    [SerializationConstructor]
+    public DeserializingConstructorStartsWithIdx1(string name) => Name = name;
+
+    public bool Equals(DeserializingConstructorStartsWithIdx1? other) => other is not null && this.Name == other.Name;
+}
diff --git a/tests/MessagePack.SourceGenerator.ExecutionTests/ExecutionTests.cs b/tests/MessagePack.SourceGenerator.ExecutionTests/ExecutionTests.cs
index 320838c58..a3dc5c411 100644
--- a/tests/MessagePack.SourceGenerator.ExecutionTests/ExecutionTests.cs
+++ b/tests/MessagePack.SourceGenerator.ExecutionTests/ExecutionTests.cs
@@ -64,6 +64,12 @@ public void ClassWithRequiredMembers()
         this.AssertRoundtrip(new HasRequiredMembers { A = 1, B = 4, C = 5, D = 6 });
     }
 
+    [Fact]
+    public void DeserializingConstructorStartsWithIdx1()
+    {
+        this.AssertRoundtrip(new DeserializingConstructorStartsWithIdx1("foo"));
+    }
+
 #if !FORCE_MAP_MODE // forced map mode simply doesn't support private fields at all as it only notices internal and public members.
     [Fact]
     public void PrivateFieldIsSerialized()
diff --git a/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs b/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
index bdea9aee6..05e7a8c9d 100644
--- a/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
+++ b/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
@@ -672,4 +672,24 @@ class A {
             ExpectedDiagnostics = { DiagnosticResult.CompilerError("MsgPack007").WithLocation(0) },
         }.RunDefaultAsync(this.testOutputHelper);
     }
+
+    [Fact]
+    public async Task NoPrivateAccessNeeded()
+    {
+        // Test case came from https://github.com/MessagePack-CSharp/MessagePack-CSharp/issues/1993
+        string testSource = /* lang=c#-test */ """
+            using MessagePack;
+
+            [MessagePackObject]
+            public class Test3
+            {
+                [Key(0)]
+                public string Name { get; private set; }
+
+                [SerializationConstructor]
+                public Test3(string name) => Name = name;
+            }
+            """;
+        await VerifyCS.Test.RunDefaultAsync(this.testOutputHelper, testSource);
+    }
 }
diff --git a/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/Formatters.MessagePack.GeneratedMessagePackResolver.Test3Formatter.g.cs b/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/Formatters.MessagePack.GeneratedMessagePackResolver.Test3Formatter.g.cs
new file mode 100644
index 000000000..145490a71
--- /dev/null
+++ b/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/Formatters.MessagePack.GeneratedMessagePackResolver.Test3Formatter.g.cs
@@ -0,0 +1,59 @@
+﻿// <auto-generated />
+
+#pragma warning disable 618, 612, 414, 168, CS1591, SA1129, SA1309, SA1312, SA1403, SA1649
+
+#pragma warning disable CS8669 // We may leak nullable annotations into generated code.
+
+using MsgPack = global::MessagePack;
+
+namespace MessagePack {
+partial class GeneratedMessagePackResolver {
+
+	internal sealed class Test3Formatter : MsgPack::Formatters.IMessagePackFormatter<global::Test3>
+	{
+
+		public void Serialize(ref MsgPack::MessagePackWriter writer, global::Test3 value, MsgPack::MessagePackSerializerOptions options)
+		{
+			if (value == null)
+			{
+				writer.WriteNil();
+				return;
+			}
+
+			MsgPack::IFormatterResolver formatterResolver = options.Resolver;
+			writer.WriteArrayHeader(1);
+			MsgPack::FormatterResolverExtensions.GetFormatterWithVerify<string>(formatterResolver).Serialize(ref writer, value.Name, options);
+		}
+
+		public global::Test3 Deserialize(ref MsgPack::MessagePackReader reader, MsgPack::MessagePackSerializerOptions options)
+		{
+			if (reader.TryReadNil())
+			{
+				return null;
+			}
+
+			options.Security.DepthStep(ref reader);
+			MsgPack::IFormatterResolver formatterResolver = options.Resolver;
+			var length = reader.ReadArrayHeader();
+			var __Name__ = default(string);
+
+			for (int i = 0; i < length; i++)
+			{
+				switch (i)
+				{
+					case 0:
+						__Name__ = MsgPack::FormatterResolverExtensions.GetFormatterWithVerify<string>(formatterResolver).Deserialize(ref reader, options);
+						break;
+					default:
+						reader.Skip();
+						break;
+				}
+			}
+
+			var ____result = new global::Test3(__Name__);
+			reader.Depth--;
+			return ____result;
+		}
+	}
+}
+}
diff --git a/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/MessagePack.GeneratedMessagePackResolver.g.cs b/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/MessagePack.GeneratedMessagePackResolver.g.cs
new file mode 100644
index 000000000..c7531dfa5
--- /dev/null
+++ b/tests/MessagePack.SourceGenerator.Tests/Resources/NoPrivateAccessNeeded/MessagePack.GeneratedMessagePackResolver.g.cs
@@ -0,0 +1,63 @@
+﻿// <auto-generated />
+
+#pragma warning disable 618, 612, 414, 168, CS1591, SA1129, SA1309, SA1312, SA1403, SA1649
+
+using MsgPack = global::MessagePack;
+
+[assembly: MsgPack::Internal.GeneratedAssemblyMessagePackResolverAttribute(typeof(MessagePack.GeneratedMessagePackResolver), 3, 0)]
+
+namespace MessagePack {
+
+/// <summary>A MessagePack resolver that uses generated formatters for types in this assembly.</summary>
+partial class GeneratedMessagePackResolver : MsgPack::IFormatterResolver
+{
+	/// <summary>An instance of this resolver that only returns formatters specifically generated for types in this assembly.</summary>
+	public static readonly MsgPack::IFormatterResolver Instance = new GeneratedMessagePackResolver();
+
+	private GeneratedMessagePackResolver()
+	{
+	}
+
+	public MsgPack::Formatters.IMessagePackFormatter<T> GetFormatter<T>()
+	{
+		return FormatterCache<T>.Formatter;
+	}
+
+	private static class FormatterCache<T>
+	{
+		internal static readonly MsgPack::Formatters.IMessagePackFormatter<T> Formatter;
+
+		static FormatterCache()
+		{
+			var f = GeneratedMessagePackResolverGetFormatterHelper.GetFormatter(typeof(T));
+			if (f != null)
+			{
+				Formatter = (MsgPack::Formatters.IMessagePackFormatter<T>)f;
+			}
+		}
+	}
+
+	private static class GeneratedMessagePackResolverGetFormatterHelper
+	{
+		private static readonly global::System.Collections.Generic.Dictionary<global::System.Type, int> closedTypeLookup = new global::System.Collections.Generic.Dictionary<global::System.Type, int>(1)
+		{
+			{ typeof(global::Test3), 0 },
+		};
+
+		internal static object GetFormatter(global::System.Type t)
+		{
+			if (closedTypeLookup.TryGetValue(t, out int closedKey))
+			{
+				switch (closedKey)
+				{
+					case 0: return new global::MessagePack.GeneratedMessagePackResolver.Test3Formatter();
+					default: return null; // unreachable
+				};
+			}
+
+			return null;
+		}
+	}
+}
+
+}