diff --git a/Benchmarks/SDK/SDK.csproj b/Benchmarks/SDK/SDK.csproj
index 94b12c0f..3b0682ae 100644
--- a/Benchmarks/SDK/SDK.csproj
+++ b/Benchmarks/SDK/SDK.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/Source/Analyzers/AggregateAnalyzer.cs b/Source/Analyzers/AggregateAnalyzer.cs
index dbf344f1..45636240 100644
--- a/Source/Analyzers/AggregateAnalyzer.cs
+++ b/Source/Analyzers/AggregateAnalyzer.cs
@@ -32,7 +32,8 @@ public class AggregateAnalyzer : DiagnosticAnalyzer
DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters,
DescriptorRules.Aggregate.MutationsCannotProduceEvents,
DescriptorRules.Events.MissingAttribute,
- DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState
+ DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState,
+ DescriptorRules.Aggregate.MutationsCannotUseCurrentTime
);
///
@@ -86,6 +87,8 @@ static HashSet CheckOnMethods(SyntaxNodeAnalysisContext context, IN
context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters, syntax.GetLocation(),
onMethod.ToDisplayString()));
}
+ EnsureMutationDoesNotAccessCurrentTime(context, syntax);
+
if (parameters.Length > 0)
{
@@ -107,6 +110,42 @@ static HashSet CheckOnMethods(SyntaxNodeAnalysisContext context, IN
return eventTypesHandled;
}
+ ///
+ /// Checks if the method gets the current time via DateTime or DateTimeOffset
+ /// Since this is not allowed for the mutations, we need to report a diagnostic
+ ///
+ ///
+ ///
+ static void EnsureMutationDoesNotAccessCurrentTime(SyntaxNodeAnalysisContext context, MethodDeclarationSyntax onMethod)
+ {
+ var currentTimeInvocations = onMethod.DescendantNodes()
+ .OfType()
+ .Where(memberAccess =>
+ {
+ var now = memberAccess.Name
+ is IdentifierNameSyntax { Identifier.Text: "Now" }
+ or IdentifierNameSyntax { Identifier.Text: "UtcNow" };
+ if (!now)
+ {
+ return false;
+ }
+
+ var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression);
+ // Check if the type is DateTime or DateTimeOffset
+ return typeInfo.Type?.ToDisplayString() == "System.DateTime" || typeInfo.Type?.ToDisplayString() == "System.DateTimeOffset";
+
+ }).ToArray();
+
+ foreach (var currentTimeInvocation in currentTimeInvocations)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DescriptorRules.Aggregate.MutationsCannotUseCurrentTime,
+ currentTimeInvocation.GetLocation(),
+ new[] { currentTimeInvocation.ToFullString() }
+ ));
+ }
+ }
+
static void CheckAggregateRootAttributePresent(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateClass)
{
var hasAttribute = aggregateClass.GetAttributes()
diff --git a/Source/Analyzers/Analyzers.csproj b/Source/Analyzers/Analyzers.csproj
index 01b33544..6c669c97 100644
--- a/Source/Analyzers/Analyzers.csproj
+++ b/Source/Analyzers/Analyzers.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs
index 400204fe..96b0069e 100644
--- a/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs
@@ -16,13 +16,20 @@
namespace Dolittle.SDK.Analyzers.CodeFixes;
///
-/// Adds On-method for the specific event type
+/// Adds On-method (mutation) for the specific event type
///
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AggregateMutationCodeFixProvider)), Shared]
public class AggregateMutationCodeFixProvider : CodeFixProvider
{
///
- public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.AggregateMissingMutationRuleId);
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DiagnosticIds.AggregateMissingMutationRuleId);
+
+ ///
+ /// Gets the fix all provider
+ ///
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
///
public override Task RegisterCodeFixesAsync(CodeFixContext context)
@@ -48,7 +55,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context)
return Task.CompletedTask;
}
- async Task GenerateStub(CodeFixContext context, Document document, string eventType, CancellationToken ct)
+ static async Task GenerateStub(CodeFixContext context, Document document, string eventType, CancellationToken ct)
{
if (await context.Document.GetSyntaxRootAsync(ct) is not CompilationUnitSyntax root) return document;
@@ -56,7 +63,8 @@ async Task GenerateStub(CodeFixContext context, Document document, str
if (member is not MethodDeclarationSyntax method) return document;
- var classDeclaration = root.DescendantNodes().OfType().First(declaration => declaration.Span.Contains(context.Span));
+ var classDeclaration = root.DescendantNodes().OfType()
+ .First(declaration => declaration.Span.Contains(context.Span));
var replacedNode = root.ReplaceNode(classDeclaration,
Formatter.Format(WithMutationMethod(classDeclaration, method), document.Project.Solution.Workspace));
diff --git a/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs
index 94ee395a..35f88bb5 100644
--- a/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs
@@ -21,7 +21,7 @@ public class AttributeIdentityCodeFixProvider : CodeFixProvider
{
///
public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.AttributeInvalidIdentityRuleId);
-
+
///
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
@@ -31,7 +31,6 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
return Task.CompletedTask;
}
-
switch (diagnostic.Id)
{
case DiagnosticIds.AttributeInvalidIdentityRuleId:
diff --git a/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs
index 17619fdb..76b4e716 100644
--- a/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs
@@ -25,7 +25,7 @@ public class AttributeMissingCodeFixProvider : CodeFixProvider
DiagnosticIds.AggregateMissingAttributeRuleId,
DiagnosticIds.EventMissingAttributeRuleId,
DiagnosticIds.ProjectionMissingAttributeRuleId);
-
+
///
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
diff --git a/Source/Analyzers/CodeFixes/CodeFixExtensions.cs b/Source/Analyzers/CodeFixes/CodeFixExtensions.cs
index 1a58d2a7..b7fa2971 100644
--- a/Source/Analyzers/CodeFixes/CodeFixExtensions.cs
+++ b/Source/Analyzers/CodeFixes/CodeFixExtensions.cs
@@ -13,19 +13,24 @@ static class Extensions
{
static readonly LineEndingsRewriter _lineEndingsRewriter = new();
- public static T WithLfLineEndings(this T replacedNode) where T : SyntaxNode => (T)_lineEndingsRewriter.Visit(replacedNode);
+ public static T WithLfLineEndings(this T replacedNode) where T : SyntaxNode =>
+ (T)_lineEndingsRewriter.Visit(replacedNode);
- public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, params INamespaceSymbol[] namespaces)
+ public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root,
+ params INamespaceSymbol[] namespaces)
{
- return root.AddMissingUsingDirectives(namespaces.Select(_ => _.ToDisplayString()).ToArray());
+ return root.AddMissingUsingDirectives(namespaces.Select(it => it.ToDisplayString()).ToArray());
}
- public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, params string[] namespaces)
+
+ public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root,
+ params string[] namespaces)
{
var usingDirectives = root.DescendantNodes().OfType().ToList();
var nonImportedNamespaces = namespaces.ToImmutableHashSet();
foreach (var usingDirective in usingDirectives)
{
+ if (usingDirective.Name is null) continue;
var usingDirectiveName = usingDirective.Name.ToString();
if (nonImportedNamespaces.Contains(usingDirectiveName))
{
@@ -35,8 +40,9 @@ public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUn
if (!nonImportedNamespaces.Any()) return root;
- var newUsingDirectives = nonImportedNamespaces.Select(namespaceName => SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))).ToArray();
-
+ var newUsingDirectives = nonImportedNamespaces
+ .Select(namespaceName => SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))).ToArray();
+
return root.AddUsings(newUsingDirectives).NormalizeWhitespace();
}
}
diff --git a/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs
index 9a428e8f..a715e371 100644
--- a/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs
@@ -14,13 +14,21 @@
namespace Dolittle.SDK.Analyzers.CodeFixes;
-[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeMissingCodeFixProvider)), Shared]
+///
+/// Code fix provider for adding EventContext parameter to event handler methods
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EventHandlerEventContextCodeFixProvider)), Shared]
public class EventHandlerEventContextCodeFixProvider : CodeFixProvider
{
+ ///
public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(
DiagnosticIds.EventHandlerMissingEventContext
);
+ /// inheritdoc
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ /// inheritdoc
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
var document = context.Document;
@@ -44,7 +52,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context)
return Task.CompletedTask;
}
- async Task AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken ct)
+ async Task AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken _)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null) return document;
@@ -67,7 +75,7 @@ async Task AddEventContextParameter(CodeFixContext context, Diagnostic
///
///
///
- MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration)
+ static MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration)
{
var existingParameters = methodDeclaration.ParameterList.Parameters;
// Get the first parameter that is not the EventContext parameter
@@ -101,7 +109,7 @@ MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax method
return methodDeclaration;
}
- public static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude)
+ static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude)
{
var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToInclude));
var existingUsings = root.Usings;
@@ -111,7 +119,7 @@ public static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSynta
// Namespace is already imported.
return root;
}
- var lineEndingTrivia = root.DescendantTrivia().First(_ => _.IsKind(SyntaxKind.EndOfLineTrivia));
+ var lineEndingTrivia = root.DescendantTrivia().First(it => it.IsKind(SyntaxKind.EndOfLineTrivia));
usingDirective = usingDirective.WithTrailingTrivia(lineEndingTrivia);
return root.WithUsings(existingUsings.Add(usingDirective));
diff --git a/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs b/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs
index aea118c1..de4a347d 100644
--- a/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs
@@ -13,15 +13,21 @@
namespace Dolittle.SDK.Analyzers.CodeFixes;
+///
+/// Fixes the visibility of a method
+///
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MethodVisibilityCodeFixProvider))]
public class MethodVisibilityCodeFixProvider : CodeFixProvider
{
+ ///
public sealed override ImmutableArray FixableDiagnosticIds =>
ImmutableArray.Create(DiagnosticIds.InvalidAccessibility,
DiagnosticIds.AggregateMutationShouldBePrivateRuleId);
- public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+ ///
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics.First();
@@ -43,7 +49,7 @@ public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
return Task.CompletedTask;
}
- void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic)
+ static void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic)
{
context.RegisterCodeFix(
CodeAction.Create(
@@ -53,7 +59,7 @@ void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic)
diagnostic);
}
- void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic)
+ static void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic)
{
context.RegisterCodeFix(
CodeAction.Create(
@@ -63,7 +69,7 @@ void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic)
diagnostic);
}
- async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken)
+ static async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken)
{
var syntaxRoot = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
@@ -79,25 +85,25 @@ async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnost
// If exists remove it
if (modifier != null)
{
- newMethod = methodDecl.WithModifiers(methodDecl
+ newMethod = newMethod.WithModifiers(methodDecl
.Modifiers.Remove(modifier)
.Add(SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
.WithLeadingTrivia(modifier.LeadingTrivia);
}
else
{
+ // Add the 'public' modifier
var newModifiers = methodDecl.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.PublicKeyword));
- newMethod = methodDecl.WithModifiers(newModifiers);
+ newMethod = newMethod.WithModifiers(newModifiers);
}
- // Add the 'public' modifier
var newSyntaxRoot = syntaxRoot.ReplaceNode(methodDecl, newMethod);
return context.Document.WithSyntaxRoot(newSyntaxRoot);
}
- async Task MakePrivateAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken)
+ static async Task MakePrivateAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken)
{
var syntaxRoot = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
diff --git a/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs
index 0bab2c7a..b9ec7707 100644
--- a/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs
+++ b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs
@@ -13,11 +13,19 @@
namespace Dolittle.SDK.Analyzers.CodeFixes;
-[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MethodVisibilityCodeFixProvider))]
+///
+/// Codefix provider for adding missing base classes according to the diagnostic
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingBaseClassCodeFixProvider))]
public class MissingBaseClassCodeFixProvider : CodeFixProvider
{
+ ///
public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.MissingBaseClassRuleId);
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ ///
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics.First();
@@ -40,7 +48,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context)
- async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, string missingClass, CancellationToken cancellationToken)
+ static async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, string missingClass, CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
@@ -56,7 +64,7 @@ async Task AddBaseClassAsync(Document document, Diagnostic diagnostic,
var newClassDeclaration = classDeclaration.AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(baseClassType.Name))).NormalizeWhitespace();
// Add using directive for the namespace if it's not already there
- var namespaceToAdd = baseClassType?.ContainingNamespace?.ToDisplayString();
+ var namespaceToAdd = baseClassType.ContainingNamespace?.ToDisplayString();
if (!string.IsNullOrEmpty(namespaceToAdd) && root is CompilationUnitSyntax compilationUnitSyntax &&
!NamespaceImported(compilationUnitSyntax, namespaceToAdd!))
{
@@ -72,7 +80,7 @@ async Task AddBaseClassAsync(Document document, Diagnostic diagnostic,
- bool NamespaceImported(CompilationUnitSyntax root, string namespaceName)
+ static bool NamespaceImported(CompilationUnitSyntax root, string namespaceName)
{
return root.Usings.Any(usingDirective => usingDirective.Name?.ToString() == namespaceName);
}
diff --git a/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs b/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs
new file mode 100644
index 00000000..cee61cc8
--- /dev/null
+++ b/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Dolittle. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Dolittle.SDK.Analyzers.CodeFixes;
+
+///
+/// Code fix provider for adding EventContext parameter to event handler methods
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ProjectionMutationEventTimeCodeFixProvider)), Shared]
+public class ProjectionMutationEventTimeCodeFixProvider : CodeFixProvider
+{
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(
+ DiagnosticIds.ProjectionMutationUsedCurrentTime
+ );
+
+ /// inheritdoc
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ /// inheritdoc
+ public override Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var document = context.Document;
+
+ foreach (var diagnostic in context.Diagnostics)
+ {
+ switch (diagnostic.Id)
+ {
+ case DiagnosticIds.ProjectionMutationUsedCurrentTime:
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ "Replace with EventContext.Occurred",
+ ct => ReplaceDateTimeNowWithEventContextOccurred(context, diagnostic, document, ct),
+ nameof(ProjectionMutationEventTimeCodeFixProvider) + ".UseEventContext"),
+ diagnostic);
+ break;
+ }
+ }
+
+
+ return Task.CompletedTask;
+ }
+
+ async Task ReplaceDateTimeNowWithEventContextOccurred(CodeFixContext context, Diagnostic diagnostic,
+ Document document, CancellationToken _)
+ {
+ if (!diagnostic.Properties.TryGetValue("expression", out var expression))
+ {
+ return document;
+ }
+
+ // Keep the type / kind of the expression
+ var replacement = expression switch
+ {
+ "System.DateTime.Now" => "Occurred.DateTime",
+ "System.DateTime.UtcNow" => "Occurred.UtcDateTime",
+ "System.DateTimeOffset.Now" => "Occurred",
+ "System.DateTimeOffset.UtcNow" => "Occurred",
+ _ => null
+ };
+
+ if (replacement is null)
+ {
+ return document;
+ }
+
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null) return document;
+
+ var existingMethod = GetMethodDeclaration(root, diagnostic);
+ if (existingMethod is null) return document;
+ var dateTimeNode = root.FindNode(diagnostic.Location.SourceSpan);
+
+ var (parameterList, eventContextParameterName) = GetParameterListAndContextName(existingMethod.ParameterList);
+
+
+ root = root.ReplaceNodes(new[] { existingMethod.ParameterList, dateTimeNode }, (original, _) =>
+ {
+ if (original == existingMethod.ParameterList)
+ {
+ return parameterList;
+ }
+
+ return SyntaxFactory.ParseExpression(eventContextParameterName + "." + replacement);
+ });
+
+
+ root = EnsureNamespaceImported((CompilationUnitSyntax)root, "Dolittle.SDK.Events");
+
+ return document.WithSyntaxRoot(root.NormalizeWhitespace(eol:"\n"));
+ }
+
+ static (ParameterListSyntax, string eventContextParameterName) GetParameterListAndContextName(
+ ParameterListSyntax parameterList)
+ {
+ if (parameterList.Parameters.Count < 2) // Missing EventContext parameter
+ {
+ // Add the EventContext parameter
+ return (WithEventContextParameter(parameterList), "ctx");
+ }
+
+ // Get the parameter name
+ var parameterName = parameterList.Parameters[1].Identifier.Text;
+ return (parameterList, parameterName);
+ }
+
+ static ParameterListSyntax WithEventContextParameter(ParameterListSyntax parameterList, string parameterName = "ctx")
+ {
+ var existingParameters = parameterList.Parameters;
+ // Get the first parameter that is not the EventContext parameter
+ var eventParameter = existingParameters.FirstOrDefault(parameter => parameter.Type?.ToString() != "EventContext");
+ if (eventParameter is null)
+ {
+ return parameterList;
+ }
+
+ var eventContextParameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName))
+ .WithType(SyntaxFactory.ParseTypeName("EventContext"));
+
+ return parameterList.WithParameters(parameterList.Parameters.Insert(1, eventContextParameter));
+ }
+
+ MethodDeclarationSyntax? GetMethodDeclaration(SyntaxNode root, Diagnostic diagnostic)
+ {
+ var diagnosticSpan = diagnostic.Location.SourceSpan;
+ var methodDeclaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf()
+ .OfType().First();
+ return methodDeclaration;
+ }
+
+ static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude)
+ {
+ var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToInclude));
+ var existingUsings = root.Usings;
+
+ if (existingUsings.Any(u => u.Name?.ToFullString() == namespaceToInclude))
+ {
+ // Namespace is already imported.
+ return root;
+ }
+
+ var lineEndingTrivia = root.DescendantTrivia().First(it => it.IsKind(SyntaxKind.EndOfLineTrivia));
+ usingDirective = usingDirective.WithTrailingTrivia(lineEndingTrivia);
+
+ return root.WithUsings(existingUsings.Add(usingDirective));
+ }
+}
diff --git a/Source/Analyzers/DescriptorRules.cs b/Source/Analyzers/DescriptorRules.cs
index 9df81b60..7a28d96d 100644
--- a/Source/Analyzers/DescriptorRules.cs
+++ b/Source/Analyzers/DescriptorRules.cs
@@ -140,7 +140,17 @@ internal static class Aggregate
DiagnosticCategories.Sdk,
DiagnosticSeverity.Error,
isEnabledByDefault: true,
- description: "Change the On-method to take a single event as a parameter");
+ description: "Change the On-method to take a single event as a parameter.");
+
+ internal static readonly DiagnosticDescriptor MutationsCannotUseCurrentTime =
+ new(
+ DiagnosticIds.AggregateMutationsCannotUseCurrentTime,
+ title: "On-methods must only use data from the event",
+ messageFormat: "'{0}' is invalid, On-methods must only use data from the event",
+ DiagnosticCategories.Sdk,
+ DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "If you need to capture the time of the event in the aggregate state, you must include it in the event.");
internal static readonly DiagnosticDescriptor MutationsCannotProduceEvents = new(
DiagnosticIds.AggregateMutationsCannotProduceEvents,
@@ -224,6 +234,16 @@ internal static class Projection
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "The event type is already handled by another On-method.");
+
+ internal static readonly DiagnosticDescriptor MutationUsedCurrentTime =
+ new(
+ DiagnosticIds.ProjectionMutationUsedCurrentTime,
+ title: "On-methods must only use data from the event or EventContext",
+ messageFormat: "'{0}' is invalid, On-methods must only use data from the event or EventContext",
+ DiagnosticCategories.Sdk,
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "If you need to get the committed time of the event, use EventContext.Occurred.");
}
}
diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs
index ba87b132..70c1b363 100644
--- a/Source/Analyzers/DiagnosticIds.cs
+++ b/Source/Analyzers/DiagnosticIds.cs
@@ -72,6 +72,10 @@ public static class DiagnosticIds
///
public const string PublicMethodsCannotMutateAggregateState = "AGG0006";
+ ///
+ /// Current time can not be used in an On-method, use the timestamp from the event instead.
+ ///
+ public const string AggregateMutationsCannotUseCurrentTime = "AGG0007";
public const string ProjectionMissingAttributeRuleId = "PROJ0001";
public const string ProjectionMissingBaseClassRuleId = "PROJ0002";
@@ -79,4 +83,5 @@ public static class DiagnosticIds
public const string ProjectionInvalidOnMethodReturnTypeRuleId = "PROJ0004";
public const string ProjectionInvalidOnMethodVisibilityRuleId = "PROJ0005";
public const string ProjectionDuplicateEventHandler = "PROJ0006";
+ public const string ProjectionMutationUsedCurrentTime = "PROJ0007";
}
diff --git a/Source/Analyzers/ProjectionsAnalyzer.cs b/Source/Analyzers/ProjectionsAnalyzer.cs
index a1cd50c1..0d3e5dc6 100644
--- a/Source/Analyzers/ProjectionsAnalyzer.cs
+++ b/Source/Analyzers/ProjectionsAnalyzer.cs
@@ -33,7 +33,8 @@ public class ProjectionsAnalyzer : DiagnosticAnalyzer
DescriptorRules.Projection.InvalidOnMethodParameters,
DescriptorRules.Projection.InvalidOnMethodReturnType,
DescriptorRules.Projection.InvalidOnMethodVisibility,
- DescriptorRules.Projection.EventTypeAlreadyHandled
+ DescriptorRules.Projection.EventTypeAlreadyHandled,
+ DescriptorRules.Projection.MutationUsedCurrentTime
);
///
@@ -61,7 +62,7 @@ static void AnalyzeProjections(SyntaxNodeAnalysisContext context)
static void CheckOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionType)
{
var members = projectionType.GetMembers();
- var onMethods = members.Where(_ => _.Name.Equals("On")).OfType().ToArray();
+ var onMethods = members.Where(method => method.Name.Equals("On")).OfType().ToArray();
var eventTypesHandled = new HashSet(SymbolEqualityComparer.Default);
foreach (var onMethod in onMethods)
@@ -117,6 +118,7 @@ static void CheckOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol p
}
CheckOnReturnType(context, projectionType, onMethod, syntax);
+ EnsureMutationDoesNotAccessCurrentTime(context, syntax);
}
}
@@ -156,6 +158,39 @@ static void CheckOnReturnType(SyntaxNodeAnalysisContext context, INamedTypeSymbo
}
}
+ ///
+ /// Checks if the method gets the current time via DateTime or DateTimeOffset
+ /// Since this is not allowed for the mutations, we need to report a diagnostic
+ ///
+ ///
+ ///
+ static void EnsureMutationDoesNotAccessCurrentTime(SyntaxNodeAnalysisContext context, MethodDeclarationSyntax onMethod)
+ {
+ foreach (var memberAccess in onMethod.DescendantNodes().OfType())
+ {
+ var property = memberAccess.Name.Identifier.Text;
+ if (property != "Now" && property != "UtcNow")
+ {
+ continue;
+ }
+
+ var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression);
+ // Check if the type is DateTime or DateTimeOffset
+ var qualifiedType = typeInfo.Type?.ToDisplayString();
+ if (qualifiedType is "System.DateTime" or "System.DateTimeOffset")
+ {
+ var properties = ImmutableDictionary.Create()
+ .Add("expression", qualifiedType + "." + property);
+ context.ReportDiagnostic(Diagnostic.Create(
+ DescriptorRules.Projection.MutationUsedCurrentTime,
+ memberAccess.GetLocation(),
+ properties: properties,
+ new[] { memberAccess.ToFullString() }
+ ));
+ }
+ }
+ }
+
static void CheckProjectionAttributePresent(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionClass)
{
var hasAttribute = projectionClass.GetAttributes()
diff --git a/Source/Analyzers/RoslynExtensions.cs b/Source/Analyzers/RoslynExtensions.cs
new file mode 100644
index 00000000..78332a3a
--- /dev/null
+++ b/Source/Analyzers/RoslynExtensions.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Dolittle. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Dolittle.SDK.Analyzers;
+
+static class RoslynExtensions
+{
+ public static MemberAccessExpressionSyntax[] GetAccessesOfCurrentTime(this SyntaxNode scope,
+ SyntaxNodeAnalysisContext context)
+ {
+ var currentTimeInvocations = scope.DescendantNodes()
+ .OfType()
+ .Where(memberAccess =>
+ {
+ var now = memberAccess.Name
+ is IdentifierNameSyntax { Identifier.Text: "Now" }
+ or IdentifierNameSyntax { Identifier.Text: "UtcNow" };
+ if (!now)
+ {
+ return false;
+ }
+
+ var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression);
+ // Check if the type is DateTime or DateTimeOffset
+ return typeInfo.Type?.ToDisplayString() == "System.DateTime" ||
+ typeInfo.Type?.ToDisplayString() == "System.DateTimeOffset";
+ }).ToArray();
+
+ return currentTimeInvocations;
+ }
+}
diff --git a/Tests/Analyzers/Analyzers.csproj b/Tests/Analyzers/Analyzers.csproj
index 1841676c..be0c6267 100644
--- a/Tests/Analyzers/Analyzers.csproj
+++ b/Tests/Analyzers/Analyzers.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs b/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs
new file mode 100644
index 00000000..b70172f5
--- /dev/null
+++ b/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Dolittle. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Threading.Tasks;
+
+namespace Dolittle.SDK.Analyzers.CodeFixes;
+
+public class ProjectionMutationEventTimeCodeFixProviderTests : CodeFixProviderTests
+{
+ [Fact]
+ public async Task ShouldFixDateTimeNow()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection: ReadModel
+{
+ public string Name { get; set; }
+ public DateTime UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt) {
+ Name = evt.Name;
+ UpdatedAt = DateTime.Now;
+ }
+}";
+
+ var expected = @"using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection : ReadModel
+{
+ public string Name { get; set; }
+ public DateTime UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt, EventContext ctx)
+ {
+ Name = evt.Name;
+ UpdatedAt = ctx.Occurred.DateTime;
+ }
+}";
+ var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime)
+ .WithSpan(17, 21, 17, 33)
+ .WithArguments("DateTime.Now");
+
+ await VerifyCodeFixAsync(test, expected, diagnosticResult);
+ }
+
+ [Fact]
+ public async Task ShouldFixDateTimeOffsetNow()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection: ReadModel
+{
+ public string Name { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt) {
+ Name = evt.Name;
+ UpdatedAt = DateTimeOffset.UtcNow;
+ }
+}";
+
+ var expected = @"using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection : ReadModel
+{
+ public string Name { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt, EventContext ctx)
+ {
+ Name = evt.Name;
+ UpdatedAt = ctx.Occurred;
+ }
+}";
+ var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime)
+ .WithSpan(17, 21, 17, 42)
+ .WithArguments("DateTimeOffset.UtcNow");
+
+ await VerifyCodeFixAsync(test, expected, diagnosticResult);
+ }
+
+ [Fact]
+ public async Task ShouldFixDateTimeOffsetNowWithPreExistingEventContext()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection: ReadModel
+{
+ public string Name { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt, EventContext context) {
+ Name = evt.Name;
+ UpdatedAt = DateTimeOffset.UtcNow;
+ }
+}";
+
+ var expected = @"using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+public record NameUpdated(string Name);
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection : ReadModel
+{
+ public string Name { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ public void On(NameUpdated evt, EventContext context)
+ {
+ Name = evt.Name;
+ UpdatedAt = context.Occurred;
+ }
+}";
+ var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime)
+ .WithSpan(17, 21, 17, 42)
+ .WithArguments("DateTimeOffset.UtcNow");
+
+ await VerifyCodeFixAsync(test, expected, diagnosticResult);
+ }
+}
diff --git a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs
index f0619389..5fa3f303 100644
--- a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs
+++ b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs
@@ -691,5 +691,131 @@ private void On(NameUpdated @event)
await VerifyAnalyzerAsync(test, expected);
}
+
+ [Fact]
+ public async Task ShouldFindDateTimeUsage()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Aggregates;
+using Dolittle.SDK.Events;
+
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+record NameUpdated(string Name);
+
+[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeAggregate: AggregateRoot
+{
+ public SomeAggregate(){
+ Name = ""John Doe"";
+ }
+
+ string Name {get; set;}
+ DateTime Timestamp { get; set; }
+
+ public void UpdateName(string name)
+ {
+ Apply(new NameUpdated(name));
+ }
+
+ private void On(NameUpdated @event)
+ {
+ Name = @event.Name;
+ Timestamp = DateTime.UtcNow;
+ }
+}";
+ DiagnosticResult[] expected =
+ {
+ Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime)
+ .WithSpan(28, 21, 28, 36)
+ .WithArguments("DateTime.UtcNow")
+ };
+
+ await VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ShouldFindDateTimeUsageWhenQualified()
+ {
+ var test = @"
+using Dolittle.SDK.Aggregates;
+using Dolittle.SDK.Events;
+
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+record NameUpdated(string Name);
+
+[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeAggregate: AggregateRoot
+{
+ public SomeAggregate(){
+ Name = ""John Doe"";
+ }
+
+ string Name {get; set;}
+ System.DateTime Timestamp { get; set; }
+
+ public void UpdateName(string name)
+ {
+ Apply(new NameUpdated(name));
+ }
+
+ private void On(NameUpdated @event)
+ {
+ Name = @event.Name;
+ Timestamp = System.DateTime.UtcNow;
+ }
+}";
+ DiagnosticResult[] expected =
+ {
+ Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime)
+ .WithSpan(27, 21, 27, 43)
+ .WithArguments("System.DateTime.UtcNow")
+ };
+
+ await VerifyAnalyzerAsync(test, expected);
+ }
+ [Fact]
+ public async Task ShouldFindDateTimeOffsetUsageWhenQualified()
+ {
+ var test = @"
+using Dolittle.SDK.Aggregates;
+using Dolittle.SDK.Events;
+
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+record NameUpdated(string Name);
+
+[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeAggregate: AggregateRoot
+{
+ public SomeAggregate(){
+ Name = ""John Doe"";
+ }
+
+ string Name {get; set;}
+ System.DateTimeOffset Timestamp { get; set; }
+
+ public void UpdateName(string name)
+ {
+ Apply(new NameUpdated(name));
+ }
+
+ private void On(NameUpdated @event)
+ {
+ Name = @event.Name;
+ Timestamp = System.DateTimeOffset.UtcNow;
+ }
+}";
+ DiagnosticResult[] expected =
+ {
+ Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime)
+ .WithSpan(27, 21, 27, 49)
+ .WithArguments("System.DateTimeOffset.UtcNow")
+ };
+
+ await VerifyAnalyzerAsync(test, expected);
+ }
}
diff --git a/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs
index 4095d17e..e5689227 100644
--- a/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs
+++ b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs
@@ -309,4 +309,73 @@ public void On(NameUpdated evt)
await VerifyAnalyzerAsync(test, expected);
}
+
+ [Fact]
+ public async Task ShouldFindDateTimeNow()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+record NameUpdated(string Name);
+
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection: ReadModel
+{
+ public string Name {get; set;}
+ public DateTime LastUpdated {get; set;}
+
+ public void On(NameUpdated evt)
+ {
+ Name = evt.Name;
+ LastUpdated = DateTime.Now;
+ }
+}";
+ DiagnosticResult[] expected =
+ {
+ Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime)
+ .WithSpan(19, 23, 19, 35)
+ .WithArguments("DateTime.Now")
+ };
+
+ await VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ShouldFindDateTimeOffsetNow()
+ {
+ var test = @"
+using System;
+using Dolittle.SDK.Projections;
+using Dolittle.SDK.Events;
+
+
+[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")]
+record NameUpdated(string Name);
+
+[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")]
+class SomeProjection: ReadModel
+{
+ public string Name {get; set;}
+ public DateTimeOffset LastUpdated {get; set;}
+
+ public void On(NameUpdated evt)
+ {
+ Name = evt.Name;
+ LastUpdated = DateTimeOffset.UtcNow;
+ }
+}";
+ DiagnosticResult[] expected =
+ {
+ Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime)
+ .WithSpan(19, 23, 19, 44)
+ .WithArguments("DateTimeOffset.UtcNow")
+ };
+
+ await VerifyAnalyzerAsync(test, expected);
+ }
+
}
diff --git a/Tests/ProjectionsTests/Occurred/SalesPerDay.cs b/Tests/ProjectionsTests/Occurred/SalesPerDay.cs
index 639ae7aa..2a013397 100644
--- a/Tests/ProjectionsTests/Occurred/SalesPerDay.cs
+++ b/Tests/ProjectionsTests/Occurred/SalesPerDay.cs
@@ -32,7 +32,7 @@ public void On(ProductSold evt, EventContext ctx)
[Projection("3dce944f-279e-4150-bef3-dd9e113220c6")]
public class SalesPerDayTotalByEventSource : ReadModel
{
- public string Store { get; private set; }
+ public string Store { get; private set; } = null!;
public DateOnly Date { get; private set; }
public decimal TotalSales { get; private set; }
@@ -58,7 +58,7 @@ class ProductKeySelector: IKeySelector
[Projection("3dce944f-279e-4150-bef3-dd9e113220c6")]
public class SalesPerDayTotalByFunction : ReadModel
{
- public string Store { get; private set; }
+ public string Store { get; private set; } = null!;
public DateOnly Date { get; private set; }
public decimal TotalSales { get; private set; }