From 69146a7e6c964e7eea4370c647e0ce0ab025ffc1 Mon Sep 17 00:00:00 2001 From: Enterprize1 Date: Thu, 29 Aug 2024 19:39:55 +0200 Subject: [PATCH] Introduce selectors (#4) * Fix namespaces * Improve test coverage * Remove UnitOfWork namespace nesting * Add first version of selectors * Add caching to selectors --- src/.config/dotnet-tools.json | 13 ++ src/.gitignore | 6 +- .../AddExtensionMiddleware.cs | 2 +- src/Fluss.HotChocolate/BuilderExtensions.cs | 1 - .../UnitOfWorkParameterExpressionBuilder.cs | 13 +- .../ServiceCollectionExtensions.cs | 2 - .../Attributes/SelectorAttribute.cs | 19 ++ src/Fluss.Regen/Fluss.Regen.csproj | 27 +++ .../Generators/SelectorSyntaxGenerator.cs | 181 ++++++++++++++++ .../Generators/StringBuilderPool.cs | 21 ++ src/Fluss.Regen/Helpers/CodeWriter.cs | 158 ++++++++++++++ .../Helpers/CodeWriterExtensions.cs | 29 +++ src/Fluss.Regen/Helpers/SymbolExtensions.cs | 9 + src/Fluss.Regen/Inspectors/ISyntaxInfo.cs | 7 + .../Inspectors/ISyntaxInspector.cs | 22 ++ src/Fluss.Regen/Inspectors/SelectorInfo.cs | 78 +++++++ .../Inspectors/SelectorInspector.cs | 48 +++++ .../Inspectors/SyntaxInfoComparer.cs | 26 +++ .../Properties/launchSettings.json | 9 + src/Fluss.Regen/Readme.md | 29 +++ src/Fluss.Regen/SelectorGenerator.cs | 189 +++++++++++++++++ src/Fluss.Testing/AggregateTestBed.cs | 12 +- .../ArbitraryUserUnitOfWorkExtensionTest.cs | 67 ++++++ .../Core/Authentication/AuthContextTest.cs | 1 - .../Core/Extensions/ValueTaskTest.cs | 8 +- .../Core/SideEffects/DispatcherTest.cs | 194 ++++++++++++++++++ .../UnitOfWorkAndAuthorizationTest.cs | 13 ++ .../Core/UnitOfWork/UnitOfWorkTest.cs | 11 +- .../Core/Validation/RootValidatorTests.cs | 118 +++++++++++ src/Fluss.UnitTest/Fluss.UnitTest.csproj | 11 +- .../Regen/SelectorGeneratorTests.cs | 118 +++++++++++ ...ncSelector#SelectorAttribute.g.verified.cs | 10 + ...esForAsyncSelector#Selectors.g.verified.cs | 82 ++++++++ ...ncSelector#SelectorAttribute.g.verified.cs | 10 + ...orNonAsyncSelector#Selectors.g.verified.cs | 54 +++++ ...rkSelector#SelectorAttribute.g.verified.cs | 10 + ...UnitOfWorkSelector#Selectors.g.verified.cs | 56 +++++ src/Fluss.UnitTest/Setup.cs | 13 ++ src/Fluss.sln | 6 + src/Fluss.sln.DotSettings.user | 12 +- src/Fluss/Aggregates/Aggregate.cs | 2 +- .../ArbitraryUserUnitOfWorkExtension.cs | 33 +-- src/Fluss/Authentication/AuthContext.cs | 1 - src/Fluss/Authentication/SystemUser.cs | 4 +- src/Fluss/Events/EventListener.cs | 15 +- src/Fluss/Fluss.csproj | 1 + src/Fluss/Fluss.csproj.DotSettings | 2 + src/Fluss/ServiceCollectionExtensions.cs | 23 ++- src/Fluss/SideEffects/SideEffect.cs | 2 +- src/Fluss/SideEffects/SideEffectDispatcher.cs | 10 +- .../SideEffectsServiceCollectionExtension.cs | 2 - src/Fluss/UnitOfWork/IUnitOfWork.cs | 14 +- src/Fluss/UnitOfWork/IWriteUnitOfWork.cs | 14 ++ src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs | 4 +- .../UnitOfWork/UnitOfWork.Authorization.cs | 2 +- src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs | 37 +++- src/Fluss/UnitOfWork/UnitOfWork.cs | 8 +- src/Fluss/UnitOfWork/UnitOfWorkFactory.cs | 10 +- .../UnitOfWork/UnitOfWorkRecordingProxy.cs | 110 ++++++++++ src/Fluss/Validation/AggregateValidator.cs | 4 +- src/Fluss/Validation/EventValidator.cs | 4 +- src/Fluss/Validation/RootValidator.cs | 55 +++-- .../ValidationServiceCollectionExtension.cs | 4 +- 63 files changed, 1943 insertions(+), 113 deletions(-) create mode 100644 src/.config/dotnet-tools.json create mode 100644 src/Fluss.Regen/Attributes/SelectorAttribute.cs create mode 100644 src/Fluss.Regen/Fluss.Regen.csproj create mode 100644 src/Fluss.Regen/Generators/SelectorSyntaxGenerator.cs create mode 100644 src/Fluss.Regen/Generators/StringBuilderPool.cs create mode 100644 src/Fluss.Regen/Helpers/CodeWriter.cs create mode 100644 src/Fluss.Regen/Helpers/CodeWriterExtensions.cs create mode 100644 src/Fluss.Regen/Helpers/SymbolExtensions.cs create mode 100644 src/Fluss.Regen/Inspectors/ISyntaxInfo.cs create mode 100644 src/Fluss.Regen/Inspectors/ISyntaxInspector.cs create mode 100644 src/Fluss.Regen/Inspectors/SelectorInfo.cs create mode 100644 src/Fluss.Regen/Inspectors/SelectorInspector.cs create mode 100644 src/Fluss.Regen/Inspectors/SyntaxInfoComparer.cs create mode 100644 src/Fluss.Regen/Properties/launchSettings.json create mode 100644 src/Fluss.Regen/Readme.md create mode 100644 src/Fluss.Regen/SelectorGenerator.cs create mode 100644 src/Fluss.UnitTest/Core/Authentication/ArbitraryUserUnitOfWorkExtensionTest.cs create mode 100644 src/Fluss.UnitTest/Core/SideEffects/DispatcherTest.cs create mode 100644 src/Fluss.UnitTest/Core/Validation/RootValidatorTests.cs create mode 100644 src/Fluss.UnitTest/Regen/SelectorGeneratorTests.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#SelectorAttribute.g.verified.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#Selectors.g.verified.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#SelectorAttribute.g.verified.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#Selectors.g.verified.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#SelectorAttribute.g.verified.cs create mode 100644 src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#Selectors.g.verified.cs create mode 100644 src/Fluss.UnitTest/Setup.cs create mode 100644 src/Fluss/Fluss.csproj.DotSettings create mode 100644 src/Fluss/UnitOfWork/IWriteUnitOfWork.cs create mode 100644 src/Fluss/UnitOfWork/UnitOfWorkRecordingProxy.cs diff --git a/src/.config/dotnet-tools.json b/src/.config/dotnet-tools.json new file mode 100644 index 0000000..e056650 --- /dev/null +++ b/src/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "verify.tool": { + "version": "0.6.0", + "commands": [ + "dotnet-verify" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore index 9226fb5..606d593 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -11,4 +11,8 @@ bld/ [Oo]bj/ msbuild.log msbuild.err -msbuild.wrn \ No newline at end of file +msbuild.wrn + +.idea + +*.received.* \ No newline at end of file diff --git a/src/Fluss.HotChocolate/AddExtensionMiddleware.cs b/src/Fluss.HotChocolate/AddExtensionMiddleware.cs index 021f9bf..d5ba394 100644 --- a/src/Fluss.HotChocolate/AddExtensionMiddleware.cs +++ b/src/Fluss.HotChocolate/AddExtensionMiddleware.cs @@ -102,7 +102,7 @@ private async IAsyncEnumerable LiveResults(IReadOnlyDictionary m.Name == nameof(ResolverContextExtensions.GetOrSetGlobalState)) - .MakeGenericMethod(typeof(UnitOfWork.UnitOfWork)); + .MakeGenericMethod(typeof(UnitOfWork)); private static readonly MethodInfo GetGlobalStateOrDefaultLongMethod = typeof(ResolverContextExtensions).GetMethods() @@ -24,20 +23,20 @@ public class UnitOfWorkParameterExpressionBuilder : IParameterExpressionBuilder typeof(IPureResolverContext).GetMethods().First( method => method.Name == nameof(IPureResolverContext.Service) && method.IsGenericMethod) - .MakeGenericMethod(typeof(UnitOfWork.UnitOfWork)); + .MakeGenericMethod(typeof(UnitOfWork)); private static readonly MethodInfo GetValueOrDefaultMethod = typeof(CollectionExtensions).GetMethods().First(m => m.Name == nameof(CollectionExtensions.GetValueOrDefault) && m.GetParameters().Length == 2); private static readonly MethodInfo WithPrefilledVersionMethod = - typeof(UnitOfWork.UnitOfWork).GetMethods(BindingFlags.Instance | BindingFlags.Public) - .First(m => m.Name == nameof(UnitOfWork.UnitOfWork.WithPrefilledVersion)); + typeof(UnitOfWork).GetMethods(BindingFlags.Instance | BindingFlags.Public) + .First(m => m.Name == nameof(UnitOfWork.WithPrefilledVersion)); private static readonly PropertyInfo ContextData = typeof(IHasContextData).GetProperty( nameof(IHasContextData.ContextData))!; - public bool CanHandle(ParameterInfo parameter) => typeof(UnitOfWork.UnitOfWork) == parameter.ParameterType + public bool CanHandle(ParameterInfo parameter) => typeof(UnitOfWork) == parameter.ParameterType || typeof(IUnitOfWork) == parameter.ParameterType; /* @@ -63,7 +62,7 @@ public Expression Build(ParameterExpressionBuilderContext builderContext) Expression.Constant(PrefillUnitOfWorkVersion))); return Expression.Call(null, GetOrSetGlobalStateUnitOfWorkMethod, context, Expression.Constant(nameof(UnitOfWork)), - Expression.Lambda>( + Expression.Lambda>( getNewUnitOfWork, Expression.Parameter(typeof(string)))); } diff --git a/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs b/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs index 91b7e07..5ad2768 100644 --- a/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs +++ b/src/Fluss.PostgreSQL/ServiceCollectionExtensions.cs @@ -1,7 +1,5 @@ using System.Reflection; using FluentMigrator.Runner; -using Fluss.Core; -using Fluss.Events; using Fluss.Upcasting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/src/Fluss.Regen/Attributes/SelectorAttribute.cs b/src/Fluss.Regen/Attributes/SelectorAttribute.cs new file mode 100644 index 0000000..a69d153 --- /dev/null +++ b/src/Fluss.Regen/Attributes/SelectorAttribute.cs @@ -0,0 +1,19 @@ +namespace Fluss.Regen.Attributes; + +public abstract class SelectorAttribute +{ + public static string FullName => $"{Namespace}.{AttributeName}"; + + private const string Namespace = "Fluss.Regen"; + private const string AttributeName = "SelectorAttribute"; + + public const string AttributeSourceCode = $@"// + +namespace {Namespace} +{{ + [System.AttributeUsage(System.AttributeTargets.Method)] + public class {AttributeName} : System.Attribute + {{ + }} +}}"; +} \ No newline at end of file diff --git a/src/Fluss.Regen/Fluss.Regen.csproj b/src/Fluss.Regen/Fluss.Regen.csproj new file mode 100644 index 0000000..585dd15 --- /dev/null +++ b/src/Fluss.Regen/Fluss.Regen.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.1 + false + enable + latest + + true + true + + Fluss.Regen + Fluss.Regen + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/Fluss.Regen/Generators/SelectorSyntaxGenerator.cs b/src/Fluss.Regen/Generators/SelectorSyntaxGenerator.cs new file mode 100644 index 0000000..965fa74 --- /dev/null +++ b/src/Fluss.Regen/Generators/SelectorSyntaxGenerator.cs @@ -0,0 +1,181 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Fluss.Regen.Helpers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Fluss.Regen.Generators; + +public sealed class SelectorSyntaxGenerator : IDisposable +{ + private StringBuilder _sb; + private CodeWriter _writer; + private bool _disposed; + + public SelectorSyntaxGenerator() + { + _sb = StringBuilderPool.Get(); + _writer = new CodeWriter(_sb); + } + + public void WriteHeader() + { + _writer.WriteFileHeader(); + _writer.WriteLine(); + _writer.WriteIndentedLine("namespace {0}", "Fluss"); + _writer.WriteIndentedLine("{"); + _writer.IncreaseIndent(); + } + + public void WriteClassHeader() + { + _writer.WriteIndentedLine("public static class UnitOfWorkSelectors"); + _writer.WriteIndentedLine("{"); + _writer.IncreaseIndent(); + _writer.WriteIndentedLine("private static global::Microsoft.Extensions.Caching.Memory.MemoryCache _cache = new (new global::Microsoft.Extensions.Caching.Memory.MemoryCacheOptions { SizeLimit = 1024 });"); + } + + public void WriteEndNamespace() + { + _writer.WriteIndentedLine(""" + private record CacheEntryValue(object? Value, global::System.Collections.Generic.IReadOnlyList? EventListeners); + private static async ValueTask MatchesEventListenerState(IUnitOfWork unitOfWork, CacheEntryValue value) { + foreach (var eventListenerData in value.EventListeners ?? global::System.Array.Empty()) { + if (!(await eventListenerData.IsStillUpToDate(unitOfWork))) { + return false; + } + } + + return true; + } + """); + + _writer.DecreaseIndent(); + _writer.WriteIndentedLine("}"); + _writer.DecreaseIndent(); + _writer.WriteIndentedLine("}"); + _writer.WriteLine(); + } + + public void WriteMethodSignatureStart(string methodName, ITypeSymbol returnType, bool noParameters) + { + _writer.WriteLine(); + _writer.WriteIndentedLine( + "public static async global::{0}<{1}> Select{2}(this global::Fluss.IUnitOfWork unitOfWork{3}", + typeof(ValueTask).FullName, + returnType.ToFullyQualified(), + methodName, + noParameters ? "" : ", "); + _writer.IncreaseIndent(); + } + + public void WriteMethodSignatureParameter(ITypeSymbol parameterType, string parameterName, bool isLast) + { + _writer.WriteIndentedLine( + "{0} {1}{2}", + parameterType.ToFullyQualified(), + parameterName, + isLast ? "" : "," + ); + } + + public void WriteMethodSignatureEnd() + { + _writer.DecreaseIndent(); + _writer.WriteIndentedLine(")"); + _writer.WriteIndentedLine("{"); + _writer.IncreaseIndent(); + } + + public void WriteRecordingUnitOfWork() + { + _writer.WriteIndentedLine("var recordingUnitOfWork = new global::Fluss.UnitOfWorkRecordingProxy(unitOfWork);"); + } + + public void WriteKeyStart(string containingType, string methodName, bool noParameters) + { + _writer.WriteIndentedLine("var key = ("); + _writer.IncreaseIndent(); + _writer.WriteIndentedLine("\"{0}.{1}\"{2}", containingType, methodName, noParameters ? "" : ","); + } + + public void WriteKeyParameter(string parameterName, bool isLast) + { + _writer.WriteIndentedLine("{0}{1}", parameterName, isLast ? "" : ","); + } + + public void WriteKeyEnd() + { + _writer.DecreaseIndent(); + _writer.WriteIndentedLine(");"); + _writer.WriteLine(); + } + + public void WriteMethodCacheHit(ITypeSymbol returnType) + { + _writer.WriteIndented("if (_cache.TryGetValue(key, out var result) && result is CacheEntryValue entryValue && await MatchesEventListenerState(unitOfWork, entryValue)) "); + using (_writer.WriteBraces()) + { + _writer.WriteIndentedLine("return ({0})entryValue.Value;", returnType.ToFullyQualified()); + } + _writer.WriteLine(); + } + + public void WriteMethodCall(string containingType, string methodName, bool isAsync) + { + _writer.WriteIndentedLine("result = {0}global::{1}.{2}(", isAsync ? "await " : "", containingType, methodName); + _writer.IncreaseIndent(); + } + + public void WriteMethodCallParameter(string parameterName, bool isLast) + { + _writer.WriteIndentedLine("{0}{1}", parameterName, isLast ? "" : ","); + } + + public void WriteMethodCallEnd(bool isAsync) + { + _writer.DecreaseIndent(); + _writer.WriteIndentedLine("){0};", isAsync ? ".ConfigureAwait(false)" : ""); + _writer.WriteLine(); + } + + public void WriteMethodCacheMiss(ITypeSymbol returnType) + { + _writer.WriteIndented("using (var entry = _cache.CreateEntry(key)) "); + + using (_writer.WriteBraces()) + { + _writer.WriteIndentedLine("entry.Value = new CacheEntryValue(result, recordingUnitOfWork.GetRecordedListeners());"); + _writer.WriteIndentedLine("entry.Size = 1;"); + } + + _writer.WriteLine(); + _writer.WriteIndentedLine("return ({0})result;", returnType.ToFullyQualified()); + } + + public void WriteMethodEnd() + { + _writer.DecreaseIndent(); + _writer.WriteIndentedLine("}"); + } + + public override string ToString() + => _sb.ToString(); + + public SourceText ToSourceText() + => SourceText.From(ToString(), Encoding.UTF8); + + public void Dispose() + { + if (_disposed) + { + return; + } + + StringBuilderPool.Return(_sb); + _sb = default!; + _writer = default!; + _disposed = true; + } +} diff --git a/src/Fluss.Regen/Generators/StringBuilderPool.cs b/src/Fluss.Regen/Generators/StringBuilderPool.cs new file mode 100644 index 0000000..b063b51 --- /dev/null +++ b/src/Fluss.Regen/Generators/StringBuilderPool.cs @@ -0,0 +1,21 @@ +using System.Text; +using System.Threading; + +namespace Fluss.Regen.Generators; + +public static class StringBuilderPool +{ + private static StringBuilder? _stringBuilder; + + public static StringBuilder Get() + { + var stringBuilder = Interlocked.Exchange(ref _stringBuilder, null); + return stringBuilder ?? new StringBuilder(); + } + + public static void Return(StringBuilder stringBuilder) + { + stringBuilder.Clear(); + Interlocked.CompareExchange(ref _stringBuilder, stringBuilder, null); + } +} diff --git a/src/Fluss.Regen/Helpers/CodeWriter.cs b/src/Fluss.Regen/Helpers/CodeWriter.cs new file mode 100644 index 0000000..1a75fb0 --- /dev/null +++ b/src/Fluss.Regen/Helpers/CodeWriter.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using System.Text; + +namespace Fluss.Regen.Helpers; + +public class CodeWriter : TextWriter +{ + private readonly TextWriter _writer; + private readonly bool _disposeWriter; + private bool _disposed; + private int _indent; + + public CodeWriter(TextWriter writer) + { + _writer = writer; + _disposeWriter = false; + } + + public CodeWriter(StringBuilder text) + { + _writer = new StringWriter(text); + _disposeWriter = true; + } + + public override Encoding Encoding { get; } = Encoding.UTF8; + + public static string Indent { get; } = new(' ', 4); + + public override void Write(char value) => + _writer.Write(value); + + public void WriteStringValue(string value) + { + Write('"'); + Write(value); + Write('"'); + } + + public void WriteIndent() + { + if (_indent > 0) + { + var spaces = _indent * 4; + for (var i = 0; i < spaces; i++) + { + Write(' '); + } + } + } + + public string GetIndentString() + { + if (_indent > 0) + { + return new string(' ', _indent * 4); + } + return string.Empty; + } + + public void WriteIndentedLine(string format, params object?[] args) + { + WriteIndent(); + + if (args.Length == 0) + { + Write(format); + } + else + { + Write(format, args); + } + + WriteLine(); + } + + public void WriteIndented(string format, params object?[] args) + { + WriteIndent(); + + if (args.Length == 0) + { + Write(format); + } + else + { + Write(format, args); + } + } + + public void WriteSpace() => Write(' '); + + public IDisposable IncreaseIndent() + { + _indent++; + return new Block(DecreaseIndent); + } + + public void DecreaseIndent() + { + if (_indent > 0) + { + _indent--; + } + } + + public IDisposable WriteBraces() + { + WriteLeftBrace(); + WriteLine(); + +#pragma warning disable CA2000 + var indent = IncreaseIndent(); +#pragma warning restore CA2000 + + return new Block(() => + { + indent.Dispose(); + WriteIndent(); + WriteRightBrace(); + WriteLine(); + }); + } + + public void WriteLeftBrace() => Write('{'); + + public void WriteRightBrace() => Write('}'); + + public override void Flush() + { + base.Flush(); + _writer.Flush(); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed && _disposeWriter) + { + if (disposing) + { + _writer.Dispose(); + } + _disposed = true; + } + } + + private sealed class Block : IDisposable + { + private readonly Action _decrease; + + public Block(Action close) + { + _decrease = close; + } + + public void Dispose() => _decrease(); + } +} \ No newline at end of file diff --git a/src/Fluss.Regen/Helpers/CodeWriterExtensions.cs b/src/Fluss.Regen/Helpers/CodeWriterExtensions.cs new file mode 100644 index 0000000..feb2ccc --- /dev/null +++ b/src/Fluss.Regen/Helpers/CodeWriterExtensions.cs @@ -0,0 +1,29 @@ +using System; + +namespace Fluss.Regen.Helpers; + +public static class CodeWriterExtensions +{ + public static void WriteFileHeader(this CodeWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + writer.WriteComment(""); + writer.WriteLine(); + writer.WriteIndentedLine("#nullable enable"); + writer.WriteLine(); + writer.WriteIndentedLine("using System;"); + writer.WriteIndentedLine("using System.Runtime.CompilerServices;"); + } + + public static CodeWriter WriteComment(this CodeWriter writer, string comment) + { + writer.WriteIndent(); + writer.Write("// "); + writer.WriteLine(comment); + return writer; + } +} diff --git a/src/Fluss.Regen/Helpers/SymbolExtensions.cs b/src/Fluss.Regen/Helpers/SymbolExtensions.cs new file mode 100644 index 0000000..777ec96 --- /dev/null +++ b/src/Fluss.Regen/Helpers/SymbolExtensions.cs @@ -0,0 +1,9 @@ +using Microsoft.CodeAnalysis; + +namespace Fluss.Regen.Helpers; + +public static class SymbolExtensions +{ + public static string ToFullyQualified(this ITypeSymbol typeSymbol) + => typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); +} diff --git a/src/Fluss.Regen/Inspectors/ISyntaxInfo.cs b/src/Fluss.Regen/Inspectors/ISyntaxInfo.cs new file mode 100644 index 0000000..4030a73 --- /dev/null +++ b/src/Fluss.Regen/Inspectors/ISyntaxInfo.cs @@ -0,0 +1,7 @@ +using System; + +namespace Fluss.Regen.Inspectors; + +public interface ISyntaxInfo : IEquatable +{ +} diff --git a/src/Fluss.Regen/Inspectors/ISyntaxInspector.cs b/src/Fluss.Regen/Inspectors/ISyntaxInspector.cs new file mode 100644 index 0000000..a64aa40 --- /dev/null +++ b/src/Fluss.Regen/Inspectors/ISyntaxInspector.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Fluss.Regen.Inspectors; + +/// +/// The syntax inspector will analyze a syntax node and try to reason out the semantics in a +/// Hot Chocolate server context. +/// +public interface ISyntaxInspector +{ + /// + /// + /// Inspects the current syntax node and if the current inspector can handle + /// the syntax will produce a syntax info. + /// + /// The syntax info is used by a syntax generator to produce source code. + /// + bool TryHandle( + GeneratorSyntaxContext context, + [NotNullWhen(true)] out ISyntaxInfo? syntaxInfo); +} diff --git a/src/Fluss.Regen/Inspectors/SelectorInfo.cs b/src/Fluss.Regen/Inspectors/SelectorInfo.cs new file mode 100644 index 0000000..359ebcb --- /dev/null +++ b/src/Fluss.Regen/Inspectors/SelectorInfo.cs @@ -0,0 +1,78 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Fluss.Regen.Inspectors; + +public sealed class SelectorInfo : ISyntaxInfo +{ + private AttributeSyntax AttributeSyntax { get; } + public IMethodSymbol MethodSymbol { get; } + private MethodDeclarationSyntax MethodSyntax { get; } + public string Name { get; } + public string Namespace { get; } + public string ContainingType { get; } + + public SelectorInfo( + AttributeSyntax attributeSyntax, + IMethodSymbol attributeSymbol, + IMethodSymbol methodSymbol, + MethodDeclarationSyntax methodSyntax + ) + { + AttributeSyntax = attributeSyntax; + MethodSymbol = methodSymbol; + MethodSyntax = methodSyntax; + + Name = methodSymbol.Name; + Namespace = methodSymbol.ContainingNamespace.ToDisplayString(); + ContainingType = methodSymbol.ContainingType.ToDisplayString(); + } + + public bool Equals(SelectorInfo? other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return AttributeSyntax.Equals(other.AttributeSyntax) && + MethodSyntax.Equals(other.MethodSyntax); + } + + public bool Equals(ISyntaxInfo other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return other is SelectorInfo info && Equals(info); + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) + || obj is SelectorInfo other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = AttributeSyntax.GetHashCode(); + hashCode = (hashCode * 397) ^ MethodSyntax.GetHashCode(); + return hashCode; + } + } + +} \ No newline at end of file diff --git a/src/Fluss.Regen/Inspectors/SelectorInspector.cs b/src/Fluss.Regen/Inspectors/SelectorInspector.cs new file mode 100644 index 0000000..0aa64ae --- /dev/null +++ b/src/Fluss.Regen/Inspectors/SelectorInspector.cs @@ -0,0 +1,48 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Fluss.Regen.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Fluss.Regen.Inspectors; + +public sealed class SelectorInspector : ISyntaxInspector +{ + public bool TryHandle( + GeneratorSyntaxContext context, + [NotNullWhen(true)] out ISyntaxInfo? syntaxInfo) + { + if (context.Node is MethodDeclarationSyntax { AttributeLists.Count: > 0, } methodSyntax) + { + foreach (var attributeListSyntax in methodSyntax.AttributeLists) + { + foreach (var attributeSyntax in attributeListSyntax.Attributes) + { + var symbol = context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol; + + if (symbol is not IMethodSymbol attributeSymbol) + { + continue; + } + + var attributeContainingTypeSymbol = attributeSymbol.ContainingType; + var fullName = attributeContainingTypeSymbol.ToDisplayString(); + + if (fullName.Equals(SelectorAttribute.FullName, StringComparison.Ordinal) && + context.SemanticModel.GetDeclaredSymbol(methodSyntax) is IMethodSymbol methodSymbol) + { + syntaxInfo = new SelectorInfo( + attributeSyntax, + attributeSymbol, + methodSymbol, + methodSyntax); + return true; + } + } + } + } + + syntaxInfo = null; + return false; + } +} \ No newline at end of file diff --git a/src/Fluss.Regen/Inspectors/SyntaxInfoComparer.cs b/src/Fluss.Regen/Inspectors/SyntaxInfoComparer.cs new file mode 100644 index 0000000..dc04df9 --- /dev/null +++ b/src/Fluss.Regen/Inspectors/SyntaxInfoComparer.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Fluss.Regen.Inspectors; + +internal sealed class SyntaxInfoComparer : IEqualityComparer +{ + public bool Equals(ISyntaxInfo? x, ISyntaxInfo? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.Equals(y); + } + + public int GetHashCode(ISyntaxInfo obj) + => obj.GetHashCode(); + + public static SyntaxInfoComparer Default { get; } = new(); +} diff --git a/src/Fluss.Regen/Properties/launchSettings.json b/src/Fluss.Regen/Properties/launchSettings.json new file mode 100644 index 0000000..b471ba0 --- /dev/null +++ b/src/Fluss.Regen/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "DebugRoslynSourceGenerator": { + "commandName": "DebugRoslynComponent", + "targetProject": "../Fluss.Regen.Sample/Fluss.Regen.Sample.csproj" + } + } +} \ No newline at end of file diff --git a/src/Fluss.Regen/Readme.md b/src/Fluss.Regen/Readme.md new file mode 100644 index 0000000..1a1cf44 --- /dev/null +++ b/src/Fluss.Regen/Readme.md @@ -0,0 +1,29 @@ +# Roslyn Source Generators Sample + +A set of three projects that illustrates Roslyn source generators. Enjoy this template to learn from and modify source generators for your own needs. + +## Content +### Fluss.Regen +A .NET Standard project with implementations of sample source generators. +**You must build this project to see the result (generated code) in the IDE.** + +- [SampleSourceGenerator.cs](SampleSourceGenerator.cs): A source generator that creates C# classes based on a text file (in this case, Domain Driven Design ubiquitous language registry). +- [SampleIncrementalSourceGenerator.cs](SampleIncrementalSourceGenerator.cs): A source generator that creates a custom report based on class properties. The target class should be annotated with the `Generators.ReportAttribute` attribute. + +### Fluss.Regen.Sample +A project that references source generators. Note the parameters of `ProjectReference` in [Fluss.Regen.Sample.csproj](../Fluss.Regen.Sample/Fluss.Regen.Sample.csproj), they make sure that the project is referenced as a set of source generators. + +### Fluss.Regen.Tests +Unit tests for source generators. The easiest way to develop language-related features is to start with unit tests. + +## How To? +### How to debug? +- Use the [launchSettings.json](Properties/launchSettings.json) profile. +- Debug tests. + +### How can I determine which syntax nodes I should expect? +Consider installing the Roslyn syntax tree viewer plugin [Rossynt](https://plugins.jetbrains.com/plugin/16902-rossynt/). + +### How to learn more about wiring source generators? +Watch the walkthrough video: [Let’s Build an Incremental Source Generator With Roslyn, by Stefan Pölz](https://youtu.be/azJm_Y2nbAI) +The complete set of information is available in [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md). \ No newline at end of file diff --git a/src/Fluss.Regen/SelectorGenerator.cs b/src/Fluss.Regen/SelectorGenerator.cs new file mode 100644 index 0000000..4ad6529 --- /dev/null +++ b/src/Fluss.Regen/SelectorGenerator.cs @@ -0,0 +1,189 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Fluss.Regen.Attributes; +using Fluss.Regen.Generators; +using Fluss.Regen.Inspectors; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + + +namespace Fluss.Regen; + +[Generator] +public class SelectorGenerator : IIncrementalGenerator +{ + private static readonly ISyntaxInspector[] Inspectors = + [ + new SelectorInspector(), + ]; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(ctx => ctx.AddSource( + "SelectorAttribute.g.cs", + SourceText.From(SelectorAttribute.AttributeSourceCode, Encoding.UTF8))); + + var modulesAndTypes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (s, _) => IsRelevant(s), + transform: TryGetModuleOrType) + .Where(static t => t is not null)! + .WithComparer(SyntaxInfoComparer.Default); + + var valueProvider = context.CompilationProvider.Combine(modulesAndTypes.Collect()); + + context.RegisterSourceOutput( + valueProvider, + static (context, source) => Execute(context, source.Right)); + } + + private static bool IsRelevant(SyntaxNode node) + => IsTypeWithAttribute(node) || + IsClassWithBaseClass(node) || + IsMethodWithAttribute(node); + + private static bool IsClassWithBaseClass(SyntaxNode node) + => node is ClassDeclarationSyntax { BaseList.Types.Count: > 0, }; + + private static bool IsTypeWithAttribute(SyntaxNode node) + => node is BaseTypeDeclarationSyntax { AttributeLists.Count: > 0, }; + + private static bool IsMethodWithAttribute(SyntaxNode node) + => node is MethodDeclarationSyntax { AttributeLists.Count: > 0, }; + + private static ISyntaxInfo? TryGetModuleOrType( + GeneratorSyntaxContext context, + CancellationToken cancellationToken) + { + foreach (var inspector in Inspectors) + { + if (inspector.TryHandle(context, out var syntaxInfo)) + { + return syntaxInfo; + } + } + + return null; + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray syntaxInfos) + { + if (syntaxInfos.IsEmpty) + { + return; + } + + var syntaxInfoList = syntaxInfos.ToList(); + WriteSelectorMethods(context, syntaxInfoList); + } + + private static void WriteSelectorMethods(SourceProductionContext context, List syntaxInfos) + { + var selectors = new List(); + + foreach (var syntaxInfo in syntaxInfos) + { + if (syntaxInfo is not SelectorInfo selector) + { + continue; + } + + selectors.Add(selector); + } + + using var generator = new SelectorSyntaxGenerator(); + generator.WriteHeader(); + generator.WriteClassHeader(); + + foreach (var selector in selectors) + { + var parametersWithoutUnitOfWork = new List(); + + foreach (var parameter in selector.MethodSymbol.Parameters) + { + if (ToTypeNameNoGenerics(parameter.Type) != "Fluss.IUnitOfWork") + { + parametersWithoutUnitOfWork.Add(parameter); + } + } + + var isAsync = ToTypeNameNoGenerics(selector.MethodSymbol.ReturnType) == typeof(ValueTask).FullName || + ToTypeNameNoGenerics(selector.MethodSymbol.ReturnType) == typeof(Task).FullName; + var hasUnitOfWorkParameter = selector.MethodSymbol.Parameters.Length != parametersWithoutUnitOfWork.Count; + + var returnType = ExtractValueType(selector.MethodSymbol.ReturnType); + + generator.WriteMethodSignatureStart(selector.Name, returnType, parametersWithoutUnitOfWork.Count == 0); + + for (var index = 0; index < parametersWithoutUnitOfWork.Count; index++) + { + var parameter = parametersWithoutUnitOfWork[index]; + generator.WriteMethodSignatureParameter(parameter.Type, parameter.Name, parametersWithoutUnitOfWork.Count - 1 == index); + } + + generator.WriteMethodSignatureEnd(); + + if (hasUnitOfWorkParameter) + { + generator.WriteRecordingUnitOfWork(); + } + + generator.WriteKeyStart(selector.ContainingType, selector.Name, parametersWithoutUnitOfWork.Count == 0); + + for (var index = 0; index < parametersWithoutUnitOfWork.Count; index++) + { + var parameter = parametersWithoutUnitOfWork[index]; + generator.WriteKeyParameter(parameter.Name, index == parametersWithoutUnitOfWork.Count - 1); + } + + generator.WriteKeyEnd(); + generator.WriteMethodCacheHit(returnType); + + generator.WriteMethodCall(selector.ContainingType, selector.Name, isAsync); + for (var index = 0; index < selector.MethodSymbol.Parameters.Length; index++) + { + var parameter = selector.MethodSymbol.Parameters[index]; + + if (ToTypeNameNoGenerics(parameter.Type) == "Fluss.IUnitOfWork") + { + generator.WriteMethodCallParameter("recordingUnitOfWork", index == selector.MethodSymbol.Parameters.Length - 1); + } + else + { + generator.WriteMethodCallParameter(parameter.Name, index == selector.MethodSymbol.Parameters.Length - 1); + } + } + + generator.WriteMethodCallEnd(isAsync); + + generator.WriteMethodCacheMiss(returnType); + + generator.WriteMethodEnd(); + } + + generator.WriteEndNamespace(); + + context.AddSource("Selectors.g.cs", generator.ToSourceText()); + } + + private static ITypeSymbol ExtractValueType(ITypeSymbol returnType) + { + if (returnType is INamedTypeSymbol namedTypeSymbol && (ToTypeNameNoGenerics(returnType) == typeof(ValueTask).FullName || + ToTypeNameNoGenerics(returnType) == typeof(Task).FullName)) + { + return namedTypeSymbol.TypeArguments[0]; + } + + return returnType; + } + + private static string ToTypeNameNoGenerics(ITypeSymbol typeSymbol) + => $"{typeSymbol.ContainingNamespace}.{typeSymbol.Name}"; +} diff --git a/src/Fluss.Testing/AggregateTestBed.cs b/src/Fluss.Testing/AggregateTestBed.cs index 876fd04..286fac3 100644 --- a/src/Fluss.Testing/AggregateTestBed.cs +++ b/src/Fluss.Testing/AggregateTestBed.cs @@ -1,9 +1,9 @@ using System.Reflection; using Fluss.Aggregates; using Fluss.Authentication; -using Fluss.Core.Validation; using Fluss.Events; using Fluss.Extensions; +using Fluss.Validation; using Moq; using Xunit; @@ -11,7 +11,7 @@ namespace Fluss.Testing; public class AggregateTestBed : EventTestBed where TAggregate : AggregateRoot, new() { - private readonly UnitOfWork.UnitOfWork _unitOfWork; + private readonly UnitOfWork _unitOfWork; private readonly IList _ignoredTypes = new List(); public AggregateTestBed() @@ -19,14 +19,14 @@ public AggregateTestBed() var validator = new Mock(); validator.Setup(v => v.ValidateEvent(It.IsAny(), It.IsAny?>())) .Returns?>((_, _) => Task.CompletedTask); - validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) - .Returns((_, _) => Task.CompletedTask); + validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) + .Returns((_, _) => Task.CompletedTask); - _unitOfWork = new UnitOfWork.UnitOfWork(EventRepository, EventListenerFactory, new[] { new AllowAllPolicy() }, + _unitOfWork = new UnitOfWork(EventRepository, EventListenerFactory, new[] { new AllowAllPolicy() }, new UserIdProvider(_ => Guid.Empty, null!), validator.Object); } - public AggregateTestBed Calling(Func action) + public AggregateTestBed Calling(Func action) { action(_unitOfWork).GetAwaiter().GetResult(); return this; diff --git a/src/Fluss.UnitTest/Core/Authentication/ArbitraryUserUnitOfWorkExtensionTest.cs b/src/Fluss.UnitTest/Core/Authentication/ArbitraryUserUnitOfWorkExtensionTest.cs new file mode 100644 index 0000000..19b53ed --- /dev/null +++ b/src/Fluss.UnitTest/Core/Authentication/ArbitraryUserUnitOfWorkExtensionTest.cs @@ -0,0 +1,67 @@ +using Fluss.Authentication; +using Fluss.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace Fluss.UnitTest.Core.Authentication; + +public class ArbitraryUserUnitOfWorkExtensionTest +{ + [Fact] + public async Task CanCreateUnitOfWorkWithArbitraryGuid() + { + var guid = Guid.NewGuid(); + + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddEventSourcing(false) + .AddBaseEventRepository() + .AddSingleton(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + // ReSharper disable once InvokeAsExtensionMethod + var unitOfWork = (Fluss.UnitOfWork)ArbitraryUserUnitOfWorkExtension.GetUserUnitOfWork(serviceProvider, guid); + await unitOfWork.Publish(new TestEvent()); + await unitOfWork.CommitInternal(); + + var inMemoryEventRepository = serviceProvider.GetRequiredService(); + var events = await inMemoryEventRepository.GetEvents(-1, 0); + + Assert.Equal(guid, events[0].ToArray()[0].By); + } + + [Fact] + public async Task CanCreateUnitOfWorkFactoryWithArbitraryGuid() + { + var guid = Guid.NewGuid(); + + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddEventSourcing(false) + .AddBaseEventRepository() + .AddSingleton(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + // ReSharper disable once InvokeAsExtensionMethod + var unitOfWorkFactory = ArbitraryUserUnitOfWorkExtension.GetUserUnitOfWorkFactory(serviceProvider, guid); + + await unitOfWorkFactory.Commit(async work => + { + await work.Publish(new TestEvent()); + }); + + var inMemoryEventRepository = serviceProvider.GetRequiredService(); + var events = await inMemoryEventRepository.GetEvents(-1, 0); + + Assert.Equal(guid, events[0].ToArray()[0].By); + } + + private class TestEvent : Event { } + + private class AllowAllPolicy : Policy + { + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs b/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs index a8ebcf8..830c624 100644 --- a/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs +++ b/src/Fluss.UnitTest/Core/Authentication/AuthContextTest.cs @@ -1,7 +1,6 @@ using Fluss.Authentication; using Fluss.Events; using Fluss.ReadModel; -using Fluss.UnitOfWork; using Moq; namespace Fluss.UnitTest.Core.Authentication; diff --git a/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs b/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs index c8a0d9c..6c819a9 100644 --- a/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs +++ b/src/Fluss.UnitTest/Core/Extensions/ValueTaskTest.cs @@ -5,25 +5,25 @@ namespace Fluss.UnitTest.Core.Extensions; public class ValueTaskTest { [Fact] - public async void AnyReturnsFalse() + public async Task AnyReturnsFalse() { Assert.False(await new[] { False() }.AnyAsync()); } [Fact] - public async void AnyReturnsTrue() + public async Task AnyReturnsTrue() { Assert.True(await new[] { False(), True() }.AnyAsync()); } [Fact] - public async void AllReturnsFalse() + public async Task AllReturnsFalse() { Assert.False(await new[] { False(), True() }.AllAsync()); } [Fact] - public async void AllReturnsTrue() + public async Task AllReturnsTrue() { Assert.True(await new[] { True(), True() }.AllAsync()); } diff --git a/src/Fluss.UnitTest/Core/SideEffects/DispatcherTest.cs b/src/Fluss.UnitTest/Core/SideEffects/DispatcherTest.cs new file mode 100644 index 0000000..a1cfd02 --- /dev/null +++ b/src/Fluss.UnitTest/Core/SideEffects/DispatcherTest.cs @@ -0,0 +1,194 @@ +using Fluss.Authentication; +using Fluss.Events; +using Fluss.Events.TransientEvents; +using Fluss.SideEffects; +using Fluss.Upcasting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Fluss.UnitTest.Core.SideEffects; + +public class DispatcherTest +{ + [Fact] + public async Task DispatchesSideEffectHandlerOnNewEvent() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddEventSourcing(false) + .ProvideUserIdFrom(_ => Guid.Empty) + .AddBaseEventRepository() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var dispatcher = serviceProvider.GetRequiredService>() + .OfType() + .Single(); + + var upcaster = serviceProvider.GetRequiredService(); + await upcaster.Run(); + + await dispatcher.StartAsync(CancellationToken.None); + + await serviceProvider.GetRequiredService().Commit(async unitOfWork => + { + await unitOfWork.Publish(new TestEvent()); + }); + + var testSideEffect = serviceProvider.GetRequiredService(); + + await WaitUntilTrue(() => testSideEffect.DidTrigger, TimeSpan.FromMilliseconds(100)); + + Assert.True(testSideEffect.DidTrigger); + + await dispatcher.StopAsync(CancellationToken.None); + } + + private class TestEvent : Event + { + } + + private class AllowAllPolicy : Policy + { + public ValueTask AuthenticateEvent(EventEnvelope envelope, IAuthContext authContext) + { + return ValueTask.FromResult(true); + } + } + + private class TestSideEffect : SideEffect + { + public bool DidTrigger { get; set; } = false; + + public Task> HandleAsync(TestEvent @event, Fluss.UnitOfWork unitOfWork) + { + DidTrigger = true; + return Task.FromResult>(Array.Empty()); + } + } + + [Fact] + public async Task DispatchesSideEffectHandlerOnNewTransientEvent() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddEventSourcing(false) + .ProvideUserIdFrom(_ => Guid.Empty) + .AddBaseEventRepository() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var dispatcher = serviceProvider.GetRequiredService>() + .OfType() + .Single(); + + var upcaster = serviceProvider.GetRequiredService(); + await upcaster.Run(); + + await dispatcher.StartAsync(CancellationToken.None); + + await serviceProvider.GetRequiredService().Commit(async unitOfWork => + { + await unitOfWork.Publish(new TestTransientEvent()); + }); + + var testTransientSideEffect = serviceProvider.GetRequiredService(); + + await WaitUntilTrue(() => testTransientSideEffect.DidTrigger, TimeSpan.FromMilliseconds(100)); + + Assert.True(testTransientSideEffect.DidTrigger); + + await dispatcher.StopAsync(CancellationToken.None); + } + + private class TestTransientEvent : TransientEvent { } + + private class TestTransientSideEffect : SideEffect + { + public bool DidTrigger { get; set; } = false; + + public Task> HandleAsync(TestTransientEvent @event, + Fluss.UnitOfWork unitOfWork) + { + DidTrigger = true; + return Task.FromResult>(Array.Empty()); + } + } + + [Fact] + public async Task PublishesNewEventsReturnedBySideEffect() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddEventSourcing(false) + .ProvideUserIdFrom(_ => Guid.Empty) + .AddBaseEventRepository() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var dispatcher = serviceProvider.GetRequiredService>() + .OfType() + .Single(); + + var upcaster = serviceProvider.GetRequiredService(); + await upcaster.Run(); + + await dispatcher.StartAsync(CancellationToken.None); + + await serviceProvider.GetRequiredService().Commit(async unitOfWork => + { + await unitOfWork.Publish(new TestTriggerEvent()); + }); + + var repository = serviceProvider.GetRequiredService(); + + await WaitUntilTrue(async () => await repository.GetLatestVersion() >= 1, TimeSpan.FromMilliseconds(100)); + + var newEvent = (await repository.GetEvents(0, 1).ToFlatEventList())[0]; + + Assert.True(newEvent.Event is TestReturnedEvent); + + await dispatcher.StopAsync(CancellationToken.None); + } + + private class TestTriggerEvent : Event { } + private class TestReturnedEvent : Event { } + + private class TestReturningSideEffect : SideEffect + { + public Task> HandleAsync(TestTriggerEvent @event, Fluss.UnitOfWork unitOfWork) + { + return Task.FromResult>(new[] { new TestReturnedEvent() }); + } + } + + private async Task WaitUntilTrue(Func> f, TimeSpan timeSpan) + { + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.CancelAfter(timeSpan); + + var cancellationToken = cancellationTokenSource.Token; + + while (!cancellationTokenSource.Token.IsCancellationRequested) + { + var b = await f(); + if (b) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken); + } + } + + private Task WaitUntilTrue(Func f, TimeSpan timeSpan) + { + return WaitUntilTrue(() => Task.FromResult(f()), timeSpan); + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs index 9ed185c..7632458 100644 --- a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs +++ b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkAndAuthorizationTest.cs @@ -48,4 +48,17 @@ public async ValueTask AuthenticateReadModel(IReadModel readModel, IAuthCo return (await authContext.GetReadModel()).HasAllowEvent; } } + + [Fact] + public async Task AnEmptyPolicyDoesNotAllowAnything() + { + Policy emptyPolicy = new EmptyPolicy(); + + Assert.False(await emptyPolicy.AuthenticateEvent(null!, null!)); + Assert.False(await emptyPolicy.AuthenticateReadModel(null!, null!)); + } + + private class EmptyPolicy : Policy + { + } } diff --git a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs index d6e157d..e9c7dff 100644 --- a/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs +++ b/src/Fluss.UnitTest/Core/UnitOfWork/UnitOfWorkTest.cs @@ -1,9 +1,8 @@ using Fluss.Aggregates; using Fluss.Authentication; -using Fluss.Core.Validation; using Fluss.Events; using Fluss.ReadModel; -using Fluss.UnitOfWork; +using Fluss.Validation; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -15,7 +14,7 @@ public partial class UnitOfWorkTest private readonly EventListenerFactory _eventListenerFactory; private readonly Guid _userId; private readonly List _policies; - private readonly Fluss.UnitOfWork.UnitOfWork _unitOfWork; + private readonly Fluss.UnitOfWork _unitOfWork; private readonly UnitOfWorkFactory _unitOfWorkFactory; private readonly Mock _validator; @@ -30,10 +29,10 @@ public UnitOfWorkTest() _validator = new Mock(MockBehavior.Strict); _validator.Setup(v => v.ValidateEvent(It.IsAny(), It.IsAny?>())) .Returns?>((_, _) => Task.CompletedTask); - _validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) - .Returns((_, _) => Task.CompletedTask); + _validator.Setup(v => v.ValidateAggregate(It.IsAny(), It.IsAny())) + .Returns((_, _) => Task.CompletedTask); - _unitOfWork = new Fluss.UnitOfWork.UnitOfWork( + _unitOfWork = new Fluss.UnitOfWork( _eventRepository, _eventListenerFactory, _policies, diff --git a/src/Fluss.UnitTest/Core/Validation/RootValidatorTests.cs b/src/Fluss.UnitTest/Core/Validation/RootValidatorTests.cs new file mode 100644 index 0000000..48f155f --- /dev/null +++ b/src/Fluss.UnitTest/Core/Validation/RootValidatorTests.cs @@ -0,0 +1,118 @@ +using Fluss.Aggregates; +using Fluss.Authentication; +using Fluss.Events; +using Fluss.Validation; +using Moq; + +namespace Fluss.UnitTest.Core.Validation; + +public class RootValidatorTests +{ + private readonly Mock _arbitraryUserUnitOfWorkCacheMock = new(MockBehavior.Strict); + private readonly Mock _unitOfWorkMock = new(MockBehavior.Strict); + + public RootValidatorTests() + { + _arbitraryUserUnitOfWorkCacheMock.Setup(c => c.GetUserUnitOfWork(It.IsAny())) + .Returns(() => _unitOfWorkMock.Object); + + _unitOfWorkMock.Setup(u => u.WithPrefilledVersion(It.IsAny())) + .Returns(() => _unitOfWorkMock.Object); + } + + [Fact] + public async Task ValidatesValidEvent() + { + var validator = new RootValidator( + _arbitraryUserUnitOfWorkCacheMock.Object, + new[] { new AggregateValidatorAlwaysValid() }, + new[] { new EventValidatorAlwaysValid() } + ); + + await validator.ValidateEvent(new EventEnvelope { Event = new TestEvent() }); + } + + [Fact] + public async Task ValidatesInvalidEvent() + { + var validator = new RootValidator( + _arbitraryUserUnitOfWorkCacheMock.Object, + new[] { new AggregateValidatorAlwaysValid() }, + new[] { new EventValidatorAlwaysInvalid() } + ); + + await Assert.ThrowsAsync(async () => + { + await validator.ValidateEvent(new EventEnvelope { Event = new TestEvent() }); + }); + } + + [Fact] + public async Task ValidatesValidAggregate() + { + var validator = new RootValidator( + _arbitraryUserUnitOfWorkCacheMock.Object, + new[] { new AggregateValidatorAlwaysValid() }, + new[] { new EventValidatorAlwaysValid() } + ); + + await validator.ValidateAggregate(new TestAggregate(), new Fluss.UnitOfWork(null!, null!, null!, null!, null!)); + } + + [Fact] + public async Task ValidatesInvalidAggregate() + { + var validator = new RootValidator( + _arbitraryUserUnitOfWorkCacheMock.Object, + new[] { new AggregateValidatorAlwaysInvalid() }, + new[] { new EventValidatorAlwaysValid() } + ); + + await Assert.ThrowsAsync(async () => + { + await validator.ValidateAggregate(new TestAggregate(), new Fluss.UnitOfWork(null!, null!, null!, null!, null!)); + }); + } + + private class TestEvent : Event { } + + private class EventValidatorAlwaysValid : EventValidator + { + public ValueTask Validate(TestEvent @event, IUnitOfWork unitOfWorkBeforeEvent) + { + return ValueTask.CompletedTask; + } + } + + private class EventValidatorAlwaysInvalid : EventValidator + { + public ValueTask Validate(TestEvent @event, IUnitOfWork unitOfWorkBeforeEvent) + { + throw new Exception("Invalid"); + } + } + + private record TestAggregate : AggregateRoot + { + protected override AggregateRoot When(EventEnvelope envelope) + { + return this; + } + } + + private class AggregateValidatorAlwaysValid : AggregateValidator + { + public ValueTask ValidateAsync(TestAggregate aggregateAfterEvent, IUnitOfWork unitOfWorkBeforeEvent) + { + return ValueTask.CompletedTask; + } + } + + private class AggregateValidatorAlwaysInvalid : AggregateValidator + { + public ValueTask ValidateAsync(TestAggregate aggregateAfterEvent, IUnitOfWork unitOfWorkBeforeEvent) + { + throw new Exception("Invalid"); + } + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Fluss.UnitTest.csproj b/src/Fluss.UnitTest/Fluss.UnitTest.csproj index 0e81098..4e07e21 100644 --- a/src/Fluss.UnitTest/Fluss.UnitTest.csproj +++ b/src/Fluss.UnitTest/Fluss.UnitTest.csproj @@ -12,8 +12,10 @@ - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,8 +26,13 @@ + + + + + diff --git a/src/Fluss.UnitTest/Regen/SelectorGeneratorTests.cs b/src/Fluss.UnitTest/Regen/SelectorGeneratorTests.cs new file mode 100644 index 0000000..4971445 --- /dev/null +++ b/src/Fluss.UnitTest/Regen/SelectorGeneratorTests.cs @@ -0,0 +1,118 @@ +using Fluss.Regen; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Fluss.UnitTest.Regen; + +public class SelectorGeneratorTests +{ + [Fact] + public Task GeneratesForAsyncSelector() + { + var generator = new SelectorGenerator(); + + var driver = CSharpGeneratorDriver.Create(generator); + + var compilation = CSharpCompilation.Create(nameof(SelectorGeneratorTests), + new[] + { + CSharpSyntaxTree.ParseText( + @" +using Fluss.Regen; +using System.Threading.Tasks; + +namespace TestNamespace; + +public class Test +{ + [Selector] + public static async ValueTask Add(int a, int b) { + return a + b; + } + + [Selector] + public static async ValueTask Add2(int a, int b) { + return a + b; + } +}") + }, + new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location) + }); + + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + return Verify(runResult); + } + + [Fact] + public Task GeneratesForNonAsyncSelector() + { + var generator = new SelectorGenerator(); + + var driver = CSharpGeneratorDriver.Create(generator); + + var compilation = CSharpCompilation.Create(nameof(SelectorGeneratorTests), + new[] + { + CSharpSyntaxTree.ParseText( + @" +using Fluss.Regen; + +namespace TestNamespace; + +public class Test +{ + [Selector] + public static int Add(int a, int b) { + return a + b; + } +}") + }, + new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location) + }); + + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + return Verify(runResult); + } + + [Fact] + public Task GeneratesForUnitOfWorkSelector() + { + var generator = new SelectorGenerator(); + + var driver = CSharpGeneratorDriver.Create(generator); + + var compilation = CSharpCompilation.Create(nameof(SelectorGeneratorTests), + new[] + { + CSharpSyntaxTree.ParseText( + @" +using Fluss; +using Fluss.Regen; + +namespace TestNamespace; + +public class Test +{ + [Selector] + public static int Add(IUnitOfWork unitOfWork, int a, int b) { + return a + b; + } +}") + }, + new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(UnitOfWork).Assembly.Location) + }); + + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + return Verify(runResult); + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#SelectorAttribute.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#SelectorAttribute.g.verified.cs new file mode 100644 index 0000000..6b9ec56 --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#SelectorAttribute.g.verified.cs @@ -0,0 +1,10 @@ +//HintName: SelectorAttribute.g.cs +// + +namespace Fluss.Regen +{ + [System.AttributeUsage(System.AttributeTargets.Method)] + public class SelectorAttribute : System.Attribute + { + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#Selectors.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#Selectors.g.verified.cs new file mode 100644 index 0000000..e7f842b --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForAsyncSelector#Selectors.g.verified.cs @@ -0,0 +1,82 @@ +//HintName: Selectors.g.cs +// + +#nullable enable + +using System; +using System.Runtime.CompilerServices; + +namespace Fluss +{ + public static class UnitOfWorkSelectors + { + private static global::Microsoft.Extensions.Caching.Memory.MemoryCache _cache = new (new global::Microsoft.Extensions.Caching.Memory.MemoryCacheOptions { SizeLimit = 1024 }); + + public static async global::System.Threading.Tasks.ValueTask SelectAdd(this global::Fluss.IUnitOfWork unitOfWork, + int a, + int b + ) + { + var key = ( + "TestNamespace.Test.Add", + a, + b + ); + + if (_cache.TryGetValue(key, out var result) && result is CacheEntryValue entryValue && await MatchesEventListenerState(unitOfWork, entryValue)) { + return (int)entryValue.Value; + } + + result = await global::TestNamespace.Test.Add( + a, + b + ).ConfigureAwait(false); + + using (var entry = _cache.CreateEntry(key)) { + entry.Value = new CacheEntryValue(result, recordingUnitOfWork.GetRecordedListeners()); + entry.Size = 1; + } + + return (int)result; + } + + public static async global::System.Threading.Tasks.ValueTask SelectAdd2(this global::Fluss.IUnitOfWork unitOfWork, + int a, + int b + ) + { + var key = ( + "TestNamespace.Test.Add2", + a, + b + ); + + if (_cache.TryGetValue(key, out var result) && result is CacheEntryValue entryValue && await MatchesEventListenerState(unitOfWork, entryValue)) { + return (int)entryValue.Value; + } + + result = await global::TestNamespace.Test.Add2( + a, + b + ).ConfigureAwait(false); + + using (var entry = _cache.CreateEntry(key)) { + entry.Value = new CacheEntryValue(result, recordingUnitOfWork.GetRecordedListeners()); + entry.Size = 1; + } + + return (int)result; + } + private record CacheEntryValue(object? Value, global::System.Collections.Generic.IReadOnlyList? EventListeners); +private static async ValueTask MatchesEventListenerState(IUnitOfWork unitOfWork, CacheEntryValue value) { + foreach (var eventListenerData in value.EventListeners ?? global::System.Array.Empty()) { + if (!(await eventListenerData.IsStillUpToDate(unitOfWork))) { + return false; + } + } + + return true; +} + } +} + diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#SelectorAttribute.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#SelectorAttribute.g.verified.cs new file mode 100644 index 0000000..6b9ec56 --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#SelectorAttribute.g.verified.cs @@ -0,0 +1,10 @@ +//HintName: SelectorAttribute.g.cs +// + +namespace Fluss.Regen +{ + [System.AttributeUsage(System.AttributeTargets.Method)] + public class SelectorAttribute : System.Attribute + { + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#Selectors.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#Selectors.g.verified.cs new file mode 100644 index 0000000..8496d3d --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForNonAsyncSelector#Selectors.g.verified.cs @@ -0,0 +1,54 @@ +//HintName: Selectors.g.cs +// + +#nullable enable + +using System; +using System.Runtime.CompilerServices; + +namespace Fluss +{ + public static class UnitOfWorkSelectors + { + private static global::Microsoft.Extensions.Caching.Memory.MemoryCache _cache = new (new global::Microsoft.Extensions.Caching.Memory.MemoryCacheOptions { SizeLimit = 1024 }); + + public static async global::System.Threading.Tasks.ValueTask SelectAdd(this global::Fluss.IUnitOfWork unitOfWork, + int a, + int b + ) + { + var key = ( + "TestNamespace.Test.Add", + a, + b + ); + + if (_cache.TryGetValue(key, out var result) && result is CacheEntryValue entryValue && await MatchesEventListenerState(unitOfWork, entryValue)) { + return (int)entryValue.Value; + } + + result = global::TestNamespace.Test.Add( + a, + b + ); + + using (var entry = _cache.CreateEntry(key)) { + entry.Value = new CacheEntryValue(result, recordingUnitOfWork.GetRecordedListeners()); + entry.Size = 1; + } + + return (int)result; + } + private record CacheEntryValue(object? Value, global::System.Collections.Generic.IReadOnlyList? EventListeners); +private static async ValueTask MatchesEventListenerState(IUnitOfWork unitOfWork, CacheEntryValue value) { + foreach (var eventListenerData in value.EventListeners ?? global::System.Array.Empty()) { + if (!(await eventListenerData.IsStillUpToDate(unitOfWork))) { + return false; + } + } + + return true; +} + } +} + diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#SelectorAttribute.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#SelectorAttribute.g.verified.cs new file mode 100644 index 0000000..6b9ec56 --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#SelectorAttribute.g.verified.cs @@ -0,0 +1,10 @@ +//HintName: SelectorAttribute.g.cs +// + +namespace Fluss.Regen +{ + [System.AttributeUsage(System.AttributeTargets.Method)] + public class SelectorAttribute : System.Attribute + { + } +} \ No newline at end of file diff --git a/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#Selectors.g.verified.cs b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#Selectors.g.verified.cs new file mode 100644 index 0000000..37b31d9 --- /dev/null +++ b/src/Fluss.UnitTest/Regen/Snapshots/SelectorGeneratorTests.GeneratesForUnitOfWorkSelector#Selectors.g.verified.cs @@ -0,0 +1,56 @@ +//HintName: Selectors.g.cs +// + +#nullable enable + +using System; +using System.Runtime.CompilerServices; + +namespace Fluss +{ + public static class UnitOfWorkSelectors + { + private static global::Microsoft.Extensions.Caching.Memory.MemoryCache _cache = new (new global::Microsoft.Extensions.Caching.Memory.MemoryCacheOptions { SizeLimit = 1024 }); + + public static async global::System.Threading.Tasks.ValueTask SelectAdd(this global::Fluss.IUnitOfWork unitOfWork, + int a, + int b + ) + { + var recordingUnitOfWork = new global::Fluss.UnitOfWorkRecordingProxy(unitOfWork); + var key = ( + "TestNamespace.Test.Add", + a, + b + ); + + if (_cache.TryGetValue(key, out var result) && result is CacheEntryValue entryValue && await MatchesEventListenerState(unitOfWork, entryValue)) { + return (int)entryValue.Value; + } + + result = global::TestNamespace.Test.Add( + recordingUnitOfWork, + a, + b + ); + + using (var entry = _cache.CreateEntry(key)) { + entry.Value = new CacheEntryValue(result, recordingUnitOfWork.GetRecordedListeners()); + entry.Size = 1; + } + + return (int)result; + } + private record CacheEntryValue(object? Value, global::System.Collections.Generic.IReadOnlyList? EventListeners); +private static async ValueTask MatchesEventListenerState(IUnitOfWork unitOfWork, CacheEntryValue value) { + foreach (var eventListenerData in value.EventListeners ?? global::System.Array.Empty()) { + if (!(await eventListenerData.IsStillUpToDate(unitOfWork))) { + return false; + } + } + + return true; +} + } +} + diff --git a/src/Fluss.UnitTest/Setup.cs b/src/Fluss.UnitTest/Setup.cs new file mode 100644 index 0000000..f72ce85 --- /dev/null +++ b/src/Fluss.UnitTest/Setup.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +namespace Fluss.UnitTest; + +public static class Setup +{ + [ModuleInitializer] + public static void Init() + { + VerifySourceGenerators.Initialize(); + Verifier.UseSourceFileRelativeDirectory("Snapshots"); + } +} \ No newline at end of file diff --git a/src/Fluss.sln b/src/Fluss.sln index 1ec6ad1..1154d39 100644 --- a/src/Fluss.sln +++ b/src/Fluss.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.UnitTest", "Fluss.Uni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.Testing", "Fluss.Testing\Fluss.Testing.csproj", "{EB267687-7C88-4DF4-85E4-ACE3FC41BB73}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fluss.Regen", "Fluss.Regen\Fluss.Regen.csproj", "{6E250321-6993-4B94-8600-85E15BF713FA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB267687-7C88-4DF4-85E4-ACE3FC41BB73}.Release|Any CPU.Build.0 = Release|Any CPU + {6E250321-6993-4B94-8600-85E15BF713FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E250321-6993-4B94-8600-85E15BF713FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E250321-6993-4B94-8600-85E15BF713FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E250321-6993-4B94-8600-85E15BF713FA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/Fluss.sln.DotSettings.user b/src/Fluss.sln.DotSettings.user index 6795caa..7c371f3 100644 --- a/src/Fluss.sln.DotSettings.user +++ b/src/Fluss.sln.DotSettings.user @@ -1,5 +1,13 @@  - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;Fluss.UnitTest&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="/home/enterprize1/Code/atmina/fluss/src/Fluss.UnitTest" Presentation="&lt;Fluss.UnitTest&gt;" /> + C:\Users\Enterprize1\AppData\Local\JetBrains\Rider2024.2\resharper-host\temp\Rider\vAny\CoverageData\_Fluss.-842573491\Snapshot\snapshot.utdcvr + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="C:\Users\Enterprize1\Code\fluss\src\Fluss.UnitTest" Presentation="&lt;Fluss.UnitTest&gt;" /> </SessionState> + DoNothing + + + + + True \ No newline at end of file diff --git a/src/Fluss/Aggregates/Aggregate.cs b/src/Fluss/Aggregates/Aggregate.cs index 57fe4b3..500200d 100644 --- a/src/Fluss/Aggregates/Aggregate.cs +++ b/src/Fluss/Aggregates/Aggregate.cs @@ -4,7 +4,7 @@ namespace Fluss.Aggregates; public abstract record AggregateRoot : EventListener, IRootEventListener { - public UnitOfWork.UnitOfWork UnitOfWork { private get; init; } = null!; + public UnitOfWork UnitOfWork { private get; init; } = null!; protected abstract override AggregateRoot When(EventEnvelope envelope); protected async ValueTask Apply(Event @event) diff --git a/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs b/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs index 6900706..bbc73bf 100644 --- a/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs +++ b/src/Fluss/Authentication/ArbitraryUserUnitOfWorkExtension.cs @@ -1,10 +1,15 @@ using System.Collections.Concurrent; -using Fluss.UnitOfWork; using Microsoft.Extensions.DependencyInjection; namespace Fluss.Authentication; -public class ArbitraryUserUnitOfWorkCache +public interface IArbitraryUserUnitOfWorkCache +{ + UnitOfWorkFactory GetUserUnitOfWorkFactory(Guid userId); + IUnitOfWork GetUserUnitOfWork(Guid userId); +} + +public class ArbitraryUserUnitOfWorkCache : IArbitraryUserUnitOfWorkCache { private readonly IServiceProvider _serviceProvider; private readonly ConcurrentDictionary _cache = new(); @@ -16,17 +21,17 @@ public ArbitraryUserUnitOfWorkCache(IServiceProvider serviceProvider) public UnitOfWorkFactory GetUserUnitOfWorkFactory(Guid userId) { - var sp = GetCachedUserUnitOfWork(userId); + var sp = GetCachedServiceProvider(userId); return sp.GetRequiredService(); } - public UnitOfWork.UnitOfWork GetUserUnitOfWork(Guid userId) + public IUnitOfWork GetUserUnitOfWork(Guid userId) { - var sp = GetCachedUserUnitOfWork(userId); - return sp.GetRequiredService(); + var sp = GetCachedServiceProvider(userId); + return sp.GetRequiredService(); } - private IServiceProvider GetCachedUserUnitOfWork(Guid userId) + private IServiceProvider GetCachedServiceProvider(Guid userId) { return _cache.GetOrAdd(userId, CreateUserServiceProvider); } @@ -34,18 +39,18 @@ private IServiceProvider GetCachedUserUnitOfWork(Guid userId) private IServiceProvider CreateUserServiceProvider(Guid providedId) { var collection = new ServiceCollection(); - var constructorArgumentTypes = typeof(UnitOfWork.UnitOfWork).GetConstructors().Single().GetParameters() + var constructorArgumentTypes = typeof(UnitOfWork).GetConstructors().Single().GetParameters() .Select(p => p.ParameterType); foreach (var type in constructorArgumentTypes) { if (type == typeof(UserIdProvider)) continue; - collection.AddSingleton(type, _serviceProvider.GetService(type)!); + collection.AddSingleton(type, _serviceProvider.GetRequiredService(type)); } collection.ProvideUserIdFrom(_ => providedId); - collection.AddTransient(); - collection.AddTransient(sp => sp.GetRequiredService()); + collection.AddTransient(); + collection.AddTransient(sp => sp.GetRequiredService()); collection.AddTransient(); return collection.BuildServiceProvider(); @@ -56,11 +61,11 @@ public static class ArbitraryUserUnitOfWorkExtension { public static UnitOfWorkFactory GetUserUnitOfWorkFactory(this IServiceProvider serviceProvider, Guid userId) { - return serviceProvider.GetRequiredService().GetUserUnitOfWorkFactory(userId); + return serviceProvider.GetRequiredService().GetUserUnitOfWorkFactory(userId); } - public static UnitOfWork.UnitOfWork GetUserUnitOfWork(this IServiceProvider serviceProvider, Guid userId) + public static IUnitOfWork GetUserUnitOfWork(this IServiceProvider serviceProvider, Guid userId) { - return serviceProvider.GetRequiredService().GetUserUnitOfWork(userId); + return serviceProvider.GetRequiredService().GetUserUnitOfWork(userId); } } diff --git a/src/Fluss/Authentication/AuthContext.cs b/src/Fluss/Authentication/AuthContext.cs index 827981b..255a395 100644 --- a/src/Fluss/Authentication/AuthContext.cs +++ b/src/Fluss/Authentication/AuthContext.cs @@ -1,6 +1,5 @@ using Fluss.Events; using Fluss.ReadModel; -using Fluss.UnitOfWork; namespace Fluss.Authentication; diff --git a/src/Fluss/Authentication/SystemUser.cs b/src/Fluss/Authentication/SystemUser.cs index dafac07..4544ac7 100644 --- a/src/Fluss/Authentication/SystemUser.cs +++ b/src/Fluss/Authentication/SystemUser.cs @@ -1,5 +1,3 @@ -using Fluss.UnitOfWork; - namespace Fluss.Authentication; public static class SystemUser @@ -11,7 +9,7 @@ public static UnitOfWorkFactory GetSystemUserUnitOfWorkFactory(this IServiceProv return serviceProvider.GetUserUnitOfWorkFactory(SystemUserGuid); } - public static UnitOfWork.UnitOfWork GetSystemUserUnitOfWork(this IServiceProvider serviceProvider) + public static IUnitOfWork GetSystemUserUnitOfWork(this IServiceProvider serviceProvider) { return serviceProvider.GetUserUnitOfWork(SystemUserGuid); } diff --git a/src/Fluss/Events/EventListener.cs b/src/Fluss/Events/EventListener.cs index e10bfc0..ee47052 100644 --- a/src/Fluss/Events/EventListener.cs +++ b/src/Fluss/Events/EventListener.cs @@ -74,7 +74,18 @@ public interface IRootEventListener { } -public interface IEventListenerWithKey +public interface IEventListenerWithKey { - public TKey Id { get; init; } + public object Id { get; init; } +} + +public interface IEventListenerWithKey : IEventListenerWithKey +{ + public new TKey Id { get; init; } + + object IEventListenerWithKey.Id + { + get => Id!; + init => Id = (TKey)value; + } } \ No newline at end of file diff --git a/src/Fluss/Fluss.csproj b/src/Fluss/Fluss.csproj index 09d03d5..2dae398 100644 --- a/src/Fluss/Fluss.csproj +++ b/src/Fluss/Fluss.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Fluss/Fluss.csproj.DotSettings b/src/Fluss/Fluss.csproj.DotSettings new file mode 100644 index 0000000..3755a63 --- /dev/null +++ b/src/Fluss/Fluss.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Fluss/ServiceCollectionExtensions.cs b/src/Fluss/ServiceCollectionExtensions.cs index 54a38be..4f8d189 100644 --- a/src/Fluss/ServiceCollectionExtensions.cs +++ b/src/Fluss/ServiceCollectionExtensions.cs @@ -3,15 +3,16 @@ using Fluss.Authentication; using Fluss.Events; using Fluss.Events.TransientEvents; -using Fluss.UnitOfWork; +using Fluss.SideEffects; using Fluss.Upcasting; +using Fluss.Validation; using Microsoft.Extensions.DependencyInjection; [assembly: InternalsVisibleTo("Fluss.UnitTest")] [assembly: InternalsVisibleTo("Fluss.HotChocolate")] [assembly: InternalsVisibleTo("Fluss.Testing")] -namespace Fluss.Core; +namespace Fluss; public static class ServiceCollectionExtensions { @@ -20,6 +21,7 @@ public static IServiceCollection AddEventSourcing(this IServiceCollection servic ArgumentNullException.ThrowIfNull(services); services + .AddLogging() .AddBaseEventRepository() .AddSingleton() .AddSingleton() @@ -35,10 +37,14 @@ public static IServiceCollection AddEventSourcing(this IServiceCollection servic return eventListenerFactory; }) - .AddSingleton() - .AddTransient() - .AddTransient(sp => sp.GetRequiredService()) - .AddTransient(); + .AddSingleton() + .AddTransient() + .AddTransient(sp => sp.GetRequiredService()) + .AddTransient() + .AddSingleton() + .AddHostedService() + .AddSingleton() + .AddSingleton(); if (addCaching) { @@ -69,7 +75,8 @@ public static IServiceCollection AddBaseEventRepository(th } return services - .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) .AddSingleton(sp => { var pipeline = sp.GetServices(); @@ -94,6 +101,6 @@ public static IServiceCollection AddUpcasters(this IServiceCollection services, services.AddSingleton(upcasterType, upcaster); } - return services.AddSingleton().AddSingleton(); + return services; } } diff --git a/src/Fluss/SideEffects/SideEffect.cs b/src/Fluss/SideEffects/SideEffect.cs index b69b7bd..ffaa40d 100644 --- a/src/Fluss/SideEffects/SideEffect.cs +++ b/src/Fluss/SideEffects/SideEffect.cs @@ -8,5 +8,5 @@ public interface SideEffect public interface SideEffect : SideEffect where T : Event { - public Task> HandleAsync(T @event, UnitOfWork.UnitOfWork unitOfWork); + public Task> HandleAsync(T @event, UnitOfWork unitOfWork); } diff --git a/src/Fluss/SideEffects/SideEffectDispatcher.cs b/src/Fluss/SideEffects/SideEffectDispatcher.cs index ef18125..56a2a63 100644 --- a/src/Fluss/SideEffects/SideEffectDispatcher.cs +++ b/src/Fluss/SideEffects/SideEffectDispatcher.cs @@ -22,14 +22,14 @@ public sealed class SideEffectDispatcher : IHostedService private readonly SemaphoreSlim _dispatchLock = new(1, 1); private readonly ILogger _logger; - public SideEffectDispatcher(IEnumerable events, IServiceProvider serviceProvider, + public SideEffectDispatcher(IEnumerable sideEffects, IServiceProvider serviceProvider, TransientEventAwareEventRepository transientEventRepository, ILogger logger) { _serviceProvider = serviceProvider; _transientEventRepository = transientEventRepository; _logger = logger; - CacheSideEffects(events); + CacheSideEffects(sideEffects); } public async Task StartAsync(CancellationToken cancellationToken) @@ -82,9 +82,9 @@ private async void HandleNewTransientEvents(object? sender, EventArgs eventArgs) } } - private void CacheSideEffects(IEnumerable events) + private void CacheSideEffects(IEnumerable sideEffects) { - foreach (var sideEffect in events) + foreach (var sideEffect in sideEffects) { var eventType = sideEffect.GetType().GetInterface(typeof(SideEffect<>).Name)!.GetGenericArguments()[0]; if (!_sideEffectCache.ContainsKey(eventType)) @@ -101,7 +101,7 @@ private async Task DispatchSideEffects(IEnumerable events) { var eventList = events.Where(e => _sideEffectCache.ContainsKey(e.Event.GetType())).ToList(); - while (eventList.Any()) + while (eventList.Count != 0) { var userId = eventList.First().By; var userEvents = eventList.TakeWhile(e => e.By == userId).ToList(); diff --git a/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs b/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs index f1617ec..6070c1c 100644 --- a/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs +++ b/src/Fluss/SideEffects/SideEffectsServiceCollectionExtension.cs @@ -14,8 +14,6 @@ public static IServiceCollection RegisterSideEffects(this IServiceCollection ser services.AddSingleton(typeof(SideEffect), @class); } - services.AddHostedService(); - return services; } } diff --git a/src/Fluss/UnitOfWork/IUnitOfWork.cs b/src/Fluss/UnitOfWork/IUnitOfWork.cs index b1808e3..abfb966 100644 --- a/src/Fluss/UnitOfWork/IUnitOfWork.cs +++ b/src/Fluss/UnitOfWork/IUnitOfWork.cs @@ -1,18 +1,16 @@ -using Fluss.Aggregates; +using System.Collections.Concurrent; using Fluss.Events; using Fluss.ReadModel; -namespace Fluss.UnitOfWork; +namespace Fluss; -// Allows mocking public interface IUnitOfWork { - ValueTask GetAggregate(TKey key) - where TAggregate : AggregateRoot, new(); - - ValueTask Publish(Event @event, AggregateRoot aggregate); ValueTask ConsistentVersion(); IReadOnlyCollection ReadModels { get; } + ConcurrentQueue PublishedEventEnvelopes { get; } + + ValueTask GetReadModel(Type tReadModel, object? key, long? at = null); ValueTask GetReadModel(long? at = null) where TReadModel : EventListener, IRootEventListener, IReadModel, new(); @@ -34,4 +32,6 @@ ValueTask UnsafeGetReadModelWithoutAuthorization(long? a ValueTask> UnsafeGetMultipleReadModelsWithoutAuthorization(IEnumerable keys, long? at = null) where TKey : notnull where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new(); + + IUnitOfWork WithPrefilledVersion(long? version); } diff --git a/src/Fluss/UnitOfWork/IWriteUnitOfWork.cs b/src/Fluss/UnitOfWork/IWriteUnitOfWork.cs new file mode 100644 index 0000000..6977f4d --- /dev/null +++ b/src/Fluss/UnitOfWork/IWriteUnitOfWork.cs @@ -0,0 +1,14 @@ +using System.Collections.Concurrent; +using Fluss.Aggregates; +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss; + +public interface IWriteUnitOfWork : IUnitOfWork +{ + ValueTask GetAggregate(TKey key) + where TAggregate : AggregateRoot, new(); + + ValueTask Publish(Event @event, AggregateRoot? aggregate = null); +} diff --git a/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs b/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs index 29661fa..05388bc 100644 --- a/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs +++ b/src/Fluss/UnitOfWork/UnitOfWork.Aggregates.cs @@ -2,12 +2,12 @@ using Fluss.Aggregates; using Fluss.Events; -namespace Fluss.UnitOfWork; +namespace Fluss; public partial class UnitOfWork { private readonly List _aggregateRoots = new(); - internal readonly ConcurrentQueue PublishedEventEnvelopes = new(); + public ConcurrentQueue PublishedEventEnvelopes { get; } = new(); public async ValueTask GetAggregate() where TAggregate : AggregateRoot, new() { diff --git a/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs b/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs index b42f639..fc0672a 100644 --- a/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs +++ b/src/Fluss/UnitOfWork/UnitOfWork.Authorization.cs @@ -5,7 +5,7 @@ using Fluss.Extensions; #endif -namespace Fluss.UnitOfWork; +namespace Fluss; public partial class UnitOfWork { diff --git a/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs b/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs index 12deb3a..f2261e4 100644 --- a/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs +++ b/src/Fluss/UnitOfWork/UnitOfWork.ReadModels.cs @@ -2,13 +2,48 @@ using Fluss.Events; using Fluss.ReadModel; -namespace Fluss.UnitOfWork; +namespace Fluss; public partial class UnitOfWork { private readonly ConcurrentBag _readModels = new(); public IReadOnlyCollection ReadModels => _readModels; + public async ValueTask GetReadModel(Type tReadModel, object? key, long? at = null) + { + using var activity = FlussActivitySource.Source.StartActivity(); + activity?.SetTag("EventSourcing.ReadModel", tReadModel.FullName); + + if (Activator.CreateInstance(tReadModel) is not EventListener eventListener) + { + throw new InvalidOperationException("Type " + tReadModel.FullName + " is not a event listener."); + } + + if (eventListener is IEventListenerWithKey eventListenerWithKey) + { + eventListenerWithKey.GetType().GetProperty("Id")?.SetValue(eventListenerWithKey, key); + } + + eventListener = await UpdateAndApplyPublished(eventListener, at); + + if (eventListener is not IReadModel readModel) + { + throw new InvalidOperationException("Type " + tReadModel.FullName + " is not a read model."); + } + + if (!await AuthorizeUsage(readModel)) + { + throw new UnauthorizedAccessException($"Cannot read {eventListener.GetType()} as the current user."); + } + + if (at is null) + { + _readModels.Add(eventListener); + } + + return readModel; + } + public async ValueTask GetReadModel(long? at = null) where TReadModel : EventListener, IRootEventListener, IReadModel, new() { diff --git a/src/Fluss/UnitOfWork/UnitOfWork.cs b/src/Fluss/UnitOfWork/UnitOfWork.cs index bbcd383..8b76dc7 100644 --- a/src/Fluss/UnitOfWork/UnitOfWork.cs +++ b/src/Fluss/UnitOfWork/UnitOfWork.cs @@ -1,10 +1,10 @@ using Fluss.Authentication; -using Fluss.Core.Validation; using Fluss.Events; +using Fluss.Validation; -namespace Fluss.UnitOfWork; +namespace Fluss; -public partial class UnitOfWork : IUnitOfWork +public partial class UnitOfWork : IWriteUnitOfWork { private readonly IEventListenerFactory _eventListenerFactory; private readonly IEventRepository _eventRepository; @@ -54,7 +54,7 @@ public async ValueTask ConsistentVersion() return await _latestVersionLoader; } - public UnitOfWork WithPrefilledVersion(long? version) + public IUnitOfWork WithPrefilledVersion(long? version) { lock (this) { diff --git a/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs b/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs index 9e35a91..bcbca28 100644 --- a/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs +++ b/src/Fluss/UnitOfWork/UnitOfWorkFactory.cs @@ -4,7 +4,7 @@ using Polly.Contrib.WaitAndRetry; using Polly.Retry; -namespace Fluss.UnitOfWork; +namespace Fluss; public class UnitOfWorkFactory { @@ -23,27 +23,27 @@ public UnitOfWorkFactory(IServiceProvider serviceProvider) .Handle() .WaitAndRetryAsync(Delay); - public async ValueTask Commit(Func action) + public async ValueTask Commit(Func action) { using var activity = FlussActivitySource.Source.StartActivity(); await RetryPolicy .ExecuteAsync(async () => { - var unitOfWork = _serviceProvider.GetRequiredService(); + var unitOfWork = _serviceProvider.GetRequiredService(); await action(unitOfWork); await unitOfWork.CommitInternal(); }); } - public async ValueTask Commit(Func> action) + public async ValueTask Commit(Func> action) { using var activity = FlussActivitySource.Source.StartActivity(); return await RetryPolicy .ExecuteAsync(async () => { - var unitOfWork = _serviceProvider.GetRequiredService(); + var unitOfWork = _serviceProvider.GetRequiredService(); var result = await action(unitOfWork); await unitOfWork.CommitInternal(); return result; diff --git a/src/Fluss/UnitOfWork/UnitOfWorkRecordingProxy.cs b/src/Fluss/UnitOfWork/UnitOfWorkRecordingProxy.cs new file mode 100644 index 0000000..12678f1 --- /dev/null +++ b/src/Fluss/UnitOfWork/UnitOfWorkRecordingProxy.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using Fluss.Events; +using Fluss.ReadModel; + +namespace Fluss; + +public class UnitOfWorkRecordingProxy : IUnitOfWork +{ + private readonly IUnitOfWork _impl; + + public UnitOfWorkRecordingProxy(IUnitOfWork impl) + { + _impl = impl; + } + + public ValueTask ConsistentVersion() + { + return _impl.ConsistentVersion(); + } + + public IReadOnlyCollection ReadModels => _impl.ReadModels; + public ConcurrentQueue PublishedEventEnvelopes => _impl.PublishedEventEnvelopes; + + public List RecordedListeners { get; } = new List(); + + public ValueTask GetReadModel(Type tReadModel, object key, long? at = null) + { + return _impl.GetReadModel(tReadModel, key, at); + } + + public ValueTask GetReadModel(long? at = null) where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + return Record(_impl.GetReadModel(at)); + } + + public ValueTask GetReadModel(TKey key, long? at = null) where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + return Record(_impl.GetReadModel(key, at)); + } + + public ValueTask UnsafeGetReadModelWithoutAuthorization(long? at = null) where TReadModel : EventListener, IRootEventListener, IReadModel, new() + { + return Record(_impl.UnsafeGetReadModelWithoutAuthorization(at)); + } + + public ValueTask UnsafeGetReadModelWithoutAuthorization(TKey key, long? at = null) where TReadModel : EventListener, IEventListenerWithKey, IReadModel, new() + { + return Record(_impl.UnsafeGetReadModelWithoutAuthorization(key, at)); + } + + public ValueTask> GetMultipleReadModels(IEnumerable keys, long? at = null) where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() where TKey : notnull + { + return Record(_impl.GetMultipleReadModels(keys, at)); + } + + public ValueTask> UnsafeGetMultipleReadModelsWithoutAuthorization(IEnumerable keys, long? at = null) where TReadModel : EventListener, IReadModel, IEventListenerWithKey, new() where TKey : notnull + { + return Record(_impl.UnsafeGetMultipleReadModelsWithoutAuthorization(keys, at)); + } + + public IUnitOfWork WithPrefilledVersion(long? version) + { + return _impl.WithPrefilledVersion(version); + } + + private async ValueTask Record(ValueTask readModel) where TReadModel : EventListener + { + var result = await readModel; + RecordedListeners.Add(result); + return result; + } + + private async ValueTask> Record(ValueTask> readModel) where TReadModel : EventListener + { + var result = await readModel; + RecordedListeners.AddRange(result); + return result; + } + + public IReadOnlyList GetRecordedListeners() + { + var eventListenerTypeWithKeyAndVersions = new List(); + + foreach (var recordedListener in RecordedListeners) + { + eventListenerTypeWithKeyAndVersions.Add(new EventListenerTypeWithKeyAndVersion( + recordedListener.GetType(), + recordedListener is IEventListenerWithKey keyListener ? keyListener.Id : null, + recordedListener.Tag.LastAccepted + )); + } + + return eventListenerTypeWithKeyAndVersions; + } + + public record EventListenerTypeWithKeyAndVersion(Type Type, object? Key, long Version) + { + public async ValueTask IsStillUpToDate(IUnitOfWork unitOfWork, long? at = null) + { + var readModel = await unitOfWork.GetReadModel(Type, Key, at); + + if (readModel is EventListener eventListener) + { + return eventListener.Tag.LastAccepted <= Version; + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/Fluss/Validation/AggregateValidator.cs b/src/Fluss/Validation/AggregateValidator.cs index ceaeb91..a4b3c1a 100644 --- a/src/Fluss/Validation/AggregateValidator.cs +++ b/src/Fluss/Validation/AggregateValidator.cs @@ -1,10 +1,10 @@ using Fluss.Aggregates; -namespace Fluss.Core.Validation; +namespace Fluss.Validation; public interface AggregateValidator { } public interface AggregateValidator : AggregateValidator where T : AggregateRoot { - ValueTask ValidateAsync(T aggregateAfterEvent, Fluss.UnitOfWork.UnitOfWork unitOfWorkBeforeEvent); + ValueTask ValidateAsync(T aggregateAfterEvent, IUnitOfWork unitOfWorkBeforeEvent); } diff --git a/src/Fluss/Validation/EventValidator.cs b/src/Fluss/Validation/EventValidator.cs index d6e3330..156adee 100644 --- a/src/Fluss/Validation/EventValidator.cs +++ b/src/Fluss/Validation/EventValidator.cs @@ -1,10 +1,10 @@ using Fluss.Events; -namespace Fluss.Core.Validation; +namespace Fluss.Validation; public interface EventValidator { } public interface EventValidator : EventValidator where T : Event { - ValueTask Validate(T @event, Fluss.UnitOfWork.UnitOfWork unitOfWorkBeforeEvent); + ValueTask Validate(T @event, IUnitOfWork unitOfWorkBeforeEvent); } diff --git a/src/Fluss/Validation/RootValidator.cs b/src/Fluss/Validation/RootValidator.cs index f04a9b2..24572ca 100644 --- a/src/Fluss/Validation/RootValidator.cs +++ b/src/Fluss/Validation/RootValidator.cs @@ -3,23 +3,23 @@ using Fluss.Authentication; using Fluss.Events; -namespace Fluss.Core.Validation; +namespace Fluss.Validation; public interface IRootValidator { public Task ValidateEvent(EventEnvelope envelope, IReadOnlyList? PreviousEnvelopes = null); - public Task ValidateAggregate(AggregateRoot aggregate, Fluss.UnitOfWork.UnitOfWork unitOfWork); + public Task ValidateAggregate(AggregateRoot aggregate, UnitOfWork unitOfWork); } public class RootValidator : IRootValidator { + private readonly IArbitraryUserUnitOfWorkCache _arbitraryUserUnitOfWorkCache; private readonly Dictionary> _aggregateValidators = new(); private readonly Dictionary> _eventValidators = new(); - private readonly IServiceProvider _serviceProvider; - public RootValidator(IEnumerable aggregateValidators, IEnumerable eventValidators, IServiceProvider serviceProvider) + public RootValidator(IArbitraryUserUnitOfWorkCache arbitraryUserUnitOfWorkCache, IEnumerable aggregateValidators, IEnumerable eventValidators) { - _serviceProvider = serviceProvider; + _arbitraryUserUnitOfWorkCache = arbitraryUserUnitOfWorkCache; CacheAggregateValidators(aggregateValidators); CacheEventValidators(eventValidators); } @@ -44,19 +44,20 @@ private void CacheEventValidators(IEnumerable validators) foreach (var validator in validators) { var eventType = validator.GetType().GetInterface(typeof(EventValidator<>).Name)!.GetGenericArguments()[0]; - if (!_eventValidators.ContainsKey(eventType)) + if (!_eventValidators.TryGetValue(eventType, out var eventTypeValidators)) { - _eventValidators[eventType] = new List<(EventValidator, MethodInfo)>(); + eventTypeValidators = new List<(EventValidator, MethodInfo)>(); + _eventValidators[eventType] = eventTypeValidators; } var method = validator.GetType().GetMethod(nameof(EventValidator.Validate))!; - _eventValidators[eventType].Add((validator, method)); + eventTypeValidators.Add((validator, method)); } } public async Task ValidateEvent(EventEnvelope envelope, IReadOnlyList? previousEnvelopes = null) { - var unitOfWork = _serviceProvider.GetUserUnitOfWork(envelope.By ?? SystemUser.SystemUserGuid); + var unitOfWork = _arbitraryUserUnitOfWorkCache.GetUserUnitOfWork(envelope.By ?? SystemUser.SystemUserGuid); var willBePublishedEnvelopes = previousEnvelopes ?? new List(); @@ -68,23 +69,45 @@ public async Task ValidateEvent(EventEnvelope envelope, IReadOnlyList + v.handler.Invoke(v.validator, new object?[] { envelope.Event, versionedUnitOfWork })); - var invocations = validators.Select(v => v.handler.Invoke(v.validator, new object?[] { envelope.Event, versionedUnitOfWork })); + await Task.WhenAll(invocations.Cast().Select(async x => await x)); + } + catch (TargetInvocationException targetInvocationException) + { + if (targetInvocationException.InnerException is { } innerException) + { + throw innerException; + } - await Task.WhenAll(invocations.Cast().Select(async x => await x)); + throw; + } } - public async Task ValidateAggregate(AggregateRoot aggregate, Fluss.UnitOfWork.UnitOfWork unitOfWork) + public async Task ValidateAggregate(AggregateRoot aggregate, UnitOfWork unitOfWork) { var type = aggregate.GetType(); if (!_aggregateValidators.TryGetValue(type, out var validator)) return; - var invocations = validator.Select(v => v.handler.Invoke(v.validator, new object?[] { aggregate, unitOfWork })); + try + { + var invocations = validator.Select(v => v.handler.Invoke(v.validator, new object?[] { aggregate, unitOfWork })); + await Task.WhenAll(invocations.Cast().Select(async x => await x)); + } + catch (TargetInvocationException targetInvocationException) + { + if (targetInvocationException.InnerException is { } innerException) + { + throw innerException; + } - await Task.WhenAll(invocations.Cast().Select(async x => await x)); + throw; + } } } diff --git a/src/Fluss/Validation/ValidationServiceCollectionExtension.cs b/src/Fluss/Validation/ValidationServiceCollectionExtension.cs index c7ba6ce..84a0216 100644 --- a/src/Fluss/Validation/ValidationServiceCollectionExtension.cs +++ b/src/Fluss/Validation/ValidationServiceCollectionExtension.cs @@ -1,7 +1,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; -namespace Fluss.Core.Validation; +namespace Fluss.Validation; public static class ValidationServiceCollectionExtension { @@ -23,8 +23,6 @@ public static IServiceCollection AddValidationFrom(this IServiceCollection servi services.AddSingleton(eventValidatorType, eventValidator); } - services.AddSingleton(); - return services; } }