Skip to content

Commit fdcd397

Browse files
PhilBastianwarwickschroederjpalac
authored
3.2 container extractor warning and analyser (#915)
* include a warning if it's likely that EnableContainerFromMessageExtractor should be set but isn't * Add Rosyln Analyser for the same --------- Co-authored-by: Warwick Schroeder <warwick.schroeder@particular.net> Co-authored-by: Jo Palac <jo.palac@particular.net>
1 parent 06b7c75 commit fdcd397

12 files changed

+717
-6
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[*.cs]
2+
3+
# Justification: Test project
4+
dotnet_diagnostic.CA2007.severity = none
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
namespace NServiceBus.Persistence.CosmosDB.Analyzers.Test;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Collections.Immutable;
6+
using System.Linq;
7+
using System.Reflection;
8+
using System.Text;
9+
using System.Text.RegularExpressions;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
using Microsoft.Azure.Cosmos;
13+
using Microsoft.CodeAnalysis;
14+
using Microsoft.CodeAnalysis.CSharp;
15+
using Microsoft.CodeAnalysis.Diagnostics;
16+
using Microsoft.CodeAnalysis.Text;
17+
using NServiceBus.TransactionalSession;
18+
using NUnit.Framework;
19+
20+
public class AnalyzerTestFixture<TAnalyzer> where TAnalyzer : DiagnosticAnalyzer, new()
21+
{
22+
protected virtual LanguageVersion AnalyzerLanguageVersion => LanguageVersion.CSharp12;
23+
24+
protected Task Assert(string markupCode, CancellationToken cancellationToken = default) =>
25+
Assert([], markupCode, [], cancellationToken);
26+
27+
protected Task Assert(string expectedDiagnosticId, string markupCode, CancellationToken cancellationToken = default) =>
28+
Assert([expectedDiagnosticId], markupCode, [], cancellationToken);
29+
30+
protected async Task Assert(string[] expectedDiagnosticIds, string markupCode, string[] ignoreDiagnosticIds, CancellationToken cancellationToken = default)
31+
{
32+
var (code, markupSpans) = Parse(markupCode);
33+
34+
var project = CreateProject(code);
35+
await WriteCode(project, cancellationToken);
36+
37+
var compilerDiagnostics = (await Task.WhenAll(project.Documents
38+
.Select(doc => doc.GetCompilerDiagnostics(cancellationToken))))
39+
.SelectMany(diagnostics => diagnostics);
40+
41+
WriteCompilerDiagnostics(compilerDiagnostics);
42+
43+
var compilation = await project.GetCompilationAsync(cancellationToken);
44+
compilation.Compile();
45+
46+
var analyzerDiagnostics = (await compilation.GetAnalyzerDiagnostics(new TAnalyzer(), cancellationToken))
47+
.Where(d => !ignoreDiagnosticIds.Contains(d.Id))
48+
.ToList();
49+
WriteAnalyzerDiagnostics(analyzerDiagnostics);
50+
51+
var expectedSpansAndIds = expectedDiagnosticIds
52+
.SelectMany(id => markupSpans.Select(span => (span.file, span.span, id)))
53+
.OrderBy(item => item.span)
54+
.ThenBy(item => item.id)
55+
.ToList();
56+
57+
var actualSpansAndIds = analyzerDiagnostics
58+
.Select(diagnostic => (diagnostic.Location.SourceTree.FilePath, diagnostic.Location.SourceSpan, diagnostic.Id))
59+
.ToList();
60+
61+
NUnit.Framework.Assert.That(actualSpansAndIds, Is.EqualTo(expectedSpansAndIds).AsCollection);
62+
}
63+
64+
protected static async Task WriteCode(Project project, CancellationToken cancellationToken = default)
65+
{
66+
if (!VerboseLogging)
67+
{
68+
return;
69+
}
70+
71+
foreach (var document in project.Documents)
72+
{
73+
Console.WriteLine(document.Name);
74+
var code = await document.GetCode(cancellationToken);
75+
foreach (var (line, index) in code.Replace("\r\n", "\n").Split('\n')
76+
.Select((line, index) => (line, index)))
77+
{
78+
Console.WriteLine($" {index + 1,3}: {line}");
79+
}
80+
}
81+
82+
}
83+
84+
static readonly ImmutableDictionary<string, ReportDiagnostic> DiagnosticOptions = new Dictionary<string, ReportDiagnostic>
85+
{
86+
{ "CS1701", ReportDiagnostic.Hidden }
87+
}
88+
.ToImmutableDictionary();
89+
90+
protected Project CreateProject(string[] code)
91+
{
92+
var workspace = new AdhocWorkspace();
93+
var project = workspace.AddProject("TestProject", LanguageNames.CSharp)
94+
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
95+
.WithSpecificDiagnosticOptions(DiagnosticOptions))
96+
.WithParseOptions(new CSharpParseOptions(AnalyzerLanguageVersion))
97+
.AddMetadataReferences(ProjectReferences);
98+
99+
for (int i = 0; i < code.Length; i++)
100+
{
101+
project = project.AddDocument($"TestDocument{i}", code[i]).Project;
102+
}
103+
104+
return project;
105+
}
106+
107+
static AnalyzerTestFixture() =>
108+
ProjectReferences = ImmutableList.Create(
109+
MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location),
110+
MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location),
111+
MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).GetTypeInfo().Assembly
112+
.Location),
113+
MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location),
114+
MetadataReference.CreateFromFile(typeof(CosmosClient).GetTypeInfo().Assembly.Location),
115+
MetadataReference.CreateFromFile(typeof(EndpointConfiguration).GetTypeInfo().Assembly.Location),
116+
MetadataReference.CreateFromFile(typeof(CosmosPersistenceConfig).GetTypeInfo().Assembly.Location),
117+
MetadataReference.CreateFromFile(typeof(CosmosOpenSessionOptions).GetTypeInfo().Assembly.Location));
118+
119+
static readonly ImmutableList<PortableExecutableReference> ProjectReferences;
120+
121+
static readonly Regex DocumentSplittingRegex = new Regex("^-{5,}.*", RegexOptions.Compiled | RegexOptions.Multiline);
122+
123+
protected static void WriteCompilerDiagnostics(IEnumerable<Diagnostic> diagnostics)
124+
{
125+
if (!VerboseLogging)
126+
{
127+
return;
128+
}
129+
130+
Console.WriteLine("Compiler diagnostics:");
131+
132+
foreach (var diagnostic in diagnostics)
133+
{
134+
Console.WriteLine($" {diagnostic}");
135+
}
136+
}
137+
138+
protected static void WriteAnalyzerDiagnostics(IEnumerable<Diagnostic> diagnostics)
139+
{
140+
if (!VerboseLogging)
141+
{
142+
return;
143+
}
144+
145+
Console.WriteLine("Analyzer diagnostics:");
146+
147+
foreach (var diagnostic in diagnostics)
148+
{
149+
Console.WriteLine($" {diagnostic}");
150+
}
151+
}
152+
153+
protected static string[] SplitMarkupCodeIntoFiles(string markupCode) =>
154+
DocumentSplittingRegex.Split(markupCode)
155+
.Where(docCode => !string.IsNullOrWhiteSpace(docCode))
156+
.ToArray();
157+
158+
static (string[] code, List<(string file, TextSpan span)>) Parse(string markupCode)
159+
{
160+
if (markupCode == null)
161+
{
162+
return ([], []);
163+
}
164+
165+
var documents = SplitMarkupCodeIntoFiles(markupCode);
166+
167+
var markupSpans = new List<(string, TextSpan)>();
168+
169+
for (var i = 0; i < documents.Length; i++)
170+
{
171+
var code = new StringBuilder();
172+
var name = $"TestDocument{i}";
173+
174+
var remainingCode = documents[i];
175+
var remainingCodeStart = 0;
176+
177+
while (remainingCode.Length > 0)
178+
{
179+
var beforeAndAfterOpening = remainingCode.Split(["[|"], 2, StringSplitOptions.None);
180+
181+
if (beforeAndAfterOpening.Length == 1)
182+
{
183+
_ = code.Append(beforeAndAfterOpening[0]);
184+
break;
185+
}
186+
187+
var midAndAfterClosing = beforeAndAfterOpening[1].Split(["|]"], 2, StringSplitOptions.None);
188+
189+
if (midAndAfterClosing.Length == 1)
190+
{
191+
throw new Exception("The markup code does not contain a closing '|]'");
192+
}
193+
194+
var markupSpan = new TextSpan(remainingCodeStart + beforeAndAfterOpening[0].Length, midAndAfterClosing[0].Length);
195+
196+
_ = code.Append(beforeAndAfterOpening[0]).Append(midAndAfterClosing[0]);
197+
markupSpans.Add((name, markupSpan));
198+
199+
remainingCode = midAndAfterClosing[1];
200+
remainingCodeStart += beforeAndAfterOpening[0].Length + markupSpan.Length;
201+
}
202+
203+
documents[i] = code.ToString();
204+
}
205+
206+
return (documents, markupSpans);
207+
}
208+
209+
protected static readonly bool VerboseLogging = Environment.GetEnvironmentVariable("CI") != "true"
210+
|| Environment.GetEnvironmentVariable("VERBOSE_TEST_LOGGING")?.ToLower() == "true";
211+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
namespace NServiceBus.Persistence.CosmosDB.Analyzers.Test;
2+
3+
using System.Threading.Tasks;
4+
using NUnit.Framework;
5+
6+
[TestFixture]
7+
public class CosmosPersistenceConfigAnalyzerTests : AnalyzerTestFixture<ContainerExtractorConfigurationAnalyzer>
8+
{
9+
[Test]
10+
public Task DiagnosticIsReportedWhenNoEnableContainerFromMessageExtractor()
11+
{
12+
var source = $$"""
13+
using NServiceBus;
14+
using System;
15+
using System.Threading.Tasks;
16+
using Microsoft.Azure.Cosmos;
17+
using NServiceBus.Persistence.CosmosDB;
18+
using System.Collections.Generic;
19+
20+
class CustomContainerFromMessageExtractor : IContainerInformationFromMessagesExtractor
21+
{
22+
public bool TryExtract(object message, IReadOnlyDictionary<string, string> headers, out ContainerInformation? containerInformation)
23+
{
24+
containerInformation = new ContainerInformation("TestContainer", new PartitionKeyPath("/key"));
25+
return true;
26+
}
27+
}
28+
29+
class Foo
30+
{
31+
void Direct(EndpointConfiguration endpointConfiguration)
32+
{
33+
var persistence = endpointConfiguration
34+
.UsePersistence<CosmosPersistence>()
35+
.CosmosClient(new CosmosClient("asdf"))
36+
.DatabaseName("Database1");
37+
38+
persistence.DefaultContainer("DefaultContainer", "/messageId");
39+
40+
var transactionInformation = persistence.TransactionInformation();
41+
[|transactionInformation.ExtractContainerInformationFromMessage(new CustomContainerFromMessageExtractor())|];
42+
}
43+
}
44+
""";
45+
46+
return Assert("NSBC001", source);
47+
}
48+
49+
[Test]
50+
public Task DiagnosticIsReportedWhenNoEnableContainerFromMessageExtractorInExtension()
51+
{
52+
var source = $$"""
53+
using NServiceBus;
54+
using System;
55+
using System.Threading.Tasks;
56+
using Microsoft.Azure.Cosmos;
57+
using NServiceBus.Persistence.CosmosDB;
58+
using System.Collections.Generic;
59+
60+
class CustomContainerFromMessageExtractor : IContainerInformationFromMessagesExtractor
61+
{
62+
public bool TryExtract(object message, IReadOnlyDictionary<string, string> headers, out ContainerInformation? containerInformation)
63+
{
64+
containerInformation = new ContainerInformation("TestContainer", new PartitionKeyPath("/key"));
65+
return true;
66+
}
67+
}
68+
69+
public static class GGExtensions
70+
{
71+
public static void ExtractContainerInformationFromMessage(this PersistenceExtensions<CosmosPersistence> persistence)
72+
{
73+
[|persistence.TransactionInformation().ExtractContainerInformationFromMessage(new CustomContainerFromMessageExtractor())|];
74+
}
75+
}
76+
77+
class Foo
78+
{
79+
void Direct(EndpointConfiguration endpointConfiguration)
80+
{
81+
var persistence = endpointConfiguration
82+
.UsePersistence<CosmosPersistence>()
83+
.CosmosClient(new CosmosClient("asdf"))
84+
.DatabaseName("Database1");
85+
86+
persistence.DefaultContainer("DefaultContainer", "/messageId");
87+
persistence.ExtractContainerInformationFromMessage();
88+
}
89+
}
90+
""";
91+
92+
return Assert("NSBC001", source);
93+
}
94+
95+
[Test]
96+
public Task DiagnosticIsNotReportedWhenEnableContainerFromMessageExtractor()
97+
{
98+
var source = $$"""
99+
using NServiceBus;
100+
using System;
101+
using System.Threading.Tasks;
102+
using Microsoft.Azure.Cosmos;
103+
using NServiceBus.Persistence.CosmosDB;
104+
using System.Collections.Generic;
105+
106+
class CustomContainerFromMessageExtractor : IContainerInformationFromMessagesExtractor
107+
{
108+
public bool TryExtract(object message, IReadOnlyDictionary<string, string> headers, out ContainerInformation? containerInformation)
109+
{
110+
containerInformation = new ContainerInformation("TestContainer", new PartitionKeyPath("/key"));
111+
return true;
112+
}
113+
}
114+
115+
class Foo
116+
{
117+
void Direct(EndpointConfiguration endpointConfiguration)
118+
{
119+
var persistence = endpointConfiguration
120+
.UsePersistence<CosmosPersistence>()
121+
.CosmosClient(new CosmosClient("asdf"))
122+
.DatabaseName("Database1")
123+
.DefaultContainer("DefaultContainer", "/messageId");
124+
persistence.EnableContainerFromMessageExtractor();
125+
126+
var transactionInformation = persistence.TransactionInformation();
127+
transactionInformation.ExtractContainerInformationFromMessage(new CustomContainerFromMessageExtractor());
128+
}
129+
}
130+
""";
131+
132+
return Assert("NSBC001", source);
133+
}
134+
135+
[Test]
136+
public Task DiagnosticIsNotReportedWhenExtractContainerInformationFromMessageOnAnotherClass()
137+
{
138+
var source = $$"""
139+
using NServiceBus;
140+
using System;
141+
using System.Threading.Tasks;
142+
using Microsoft.Azure.Cosmos;
143+
using NServiceBus.Persistence.CosmosDB;
144+
using System.Collections.Generic;
145+
146+
class CustomContainerFromMessageExtractor : IContainerInformationFromMessagesExtractor
147+
{
148+
public bool TryExtract(object message, IReadOnlyDictionary<string, string> headers, out ContainerInformation? containerInformation)
149+
{
150+
containerInformation = new ContainerInformation("TestContainer", new PartitionKeyPath("/key"));
151+
return true;
152+
}
153+
}
154+
155+
class SomeOtherClass
156+
{
157+
internal void ExtractContainerInformationFromMessage() { }
158+
}
159+
160+
class Foo
161+
{
162+
void Direct(EndpointConfiguration endpointConfiguration, SomeOtherClass otherClass)
163+
{
164+
var persistence = endpointConfiguration
165+
.UsePersistence<CosmosPersistence>()
166+
.CosmosClient(new CosmosClient("asdf"))
167+
.DatabaseName("Database1")
168+
.DefaultContainer("DefaultContainer", "/messageId");
169+
otherClass.ExtractContainerInformationFromMessage();
170+
}
171+
}
172+
""";
173+
174+
return Assert("NSBC001", source);
175+
}
176+
}

0 commit comments

Comments
 (0)