diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 73eaf9da..e12dcfaf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,10 @@ "Bash(for dir in RCommon.*.Tests)", "Bash(do echo \"=== $dir ===\")", "Bash(done)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(findstr:*)", + "Bash(Select-String -Pattern \"warning CS8\")", + "Bash(Select-String -Pattern \"Build succeeded|Error\\\\\\(s\\\\\\)|Warning\\\\\\(s\\\\\\)|error CS\")" ] } } diff --git a/.gitignore b/.gitignore index e4e4b4f5..011ff5a6 100644 --- a/.gitignore +++ b/.gitignore @@ -342,3 +342,6 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb /Src/RCommon.Persistence.EfCore/IEFCoreConfiguration.cs +/.claude +.claude/settings.local.json +.claude/settings.local.json diff --git a/Src/.editorconfig b/Src/.editorconfig new file mode 100644 index 00000000..c85b034f --- /dev/null +++ b/Src/.editorconfig @@ -0,0 +1,157 @@ +############################### +# Core EditorConfig Options # +############################### + +root = true + +# All files +[*] +indent_style = space + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 +insert_final_newline = true +charset = utf-8-bom + +############################### +# .NET Coding Conventions # +############################### + +############################### +# .NET Coding Conventions # +############################### + +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### + +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +############################### +# C# Code Style Rules # +############################### + +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent + +# Pattern-matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_after_comma = true +csharp_space_after_dot = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + +################################## +# Visual Basic Code Style Rules # +################################## + +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion diff --git a/Src/RCommon.ApplicationServices/Commands/CommandBus.cs b/Src/RCommon.ApplicationServices/Commands/CommandBus.cs index 6e8b0f1c..cbe8af5a 100644 --- a/Src/RCommon.ApplicationServices/Commands/CommandBus.cs +++ b/Src/RCommon.ApplicationServices/Commands/CommandBus.cs @@ -39,16 +39,33 @@ namespace RCommon.ApplicationServices.Commands { + /// + /// Default implementation of that dispatches commands to their registered handlers + /// using the dependency injection container. + /// + /// + /// The command bus resolves the appropriate from the service provider, + /// optionally validates the command via , and invokes the handler using + /// a dynamically compiled delegate. Compiled handler delegates can optionally be cached for improved performance. + /// public class CommandBus : ICommandBus { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly IValidationService _validationService; private readonly IOptions _validationOptions; - private ICacheService _cacheService; + private ICacheService? _cacheService; private readonly CachingOptions _cachingOptions; - public CommandBus(ILogger logger, IServiceProvider serviceProvider, IValidationService validationService, + /// + /// Initializes a new instance of . + /// + /// Logger for tracing command execution. + /// Service provider used to resolve command handlers. + /// Service used to validate commands before execution. + /// Options controlling whether command validation is enabled. + /// Options controlling whether dynamically compiled expressions are cached. + public CommandBus(ILogger logger, IServiceProvider serviceProvider, IValidationService validationService, IOptions validationOptions, IOptions cachingOptions) { _logger = logger; @@ -58,11 +75,13 @@ public CommandBus(ILogger logger, IServiceProvider serviceProvider, _cachingOptions = cachingOptions.Value; } + /// public async Task DispatchCommandAsync(ICommand command, CancellationToken cancellationToken = default) where TResult : IExecutionResult { if (command == null) throw new ArgumentNullException(nameof(command)); + // Validate the command if validation is configured for commands if (_validationOptions.Value != null && _validationOptions.Value.ValidateCommands) { // TODO: Would be nice to be able to take validation outcome and put in FailedExecutionResult. Need some casting magic @@ -86,15 +105,25 @@ public async Task DispatchCommandAsync(ICommand comma commandResult?.IsSuccess); } - return commandResult; + return commandResult!; } + /// + /// Resolves and invokes the single registered handler for the given command. + /// + /// The execution result type returned by the handler. + /// The command to execute. + /// Token to observe for cancellation. + /// The result produced by the command handler. + /// Thrown when no handler is registered for the command type. + /// Thrown when more than one handler is registered for the command type. private async Task ExecuteHandlerAsync(ICommand command, CancellationToken cancellationToken) where TResult : IExecutionResult { var commandType = command.GetType(); var commandExecutionDetails = GetCommandExecutionDetails(commandType); + // Resolve all registered handlers for this command type and enforce exactly one var commandHandlers = _serviceProvider.GetServices(commandExecutionDetails.CommandHandlerType) .Cast() .ToList(); @@ -114,14 +143,21 @@ private async Task ExecuteHandlerAsync(ICommand comma var commandHandler = commandHandlers.Single(); + // Invoke the handler via the dynamically compiled delegate var task = (Task)commandExecutionDetails.Invoker(commandHandler, command, cancellationToken); return await task; } + /// + /// Holds the resolved handler type and the compiled delegate used to invoke it. + /// private class CommandExecutionDetails { - public Type CommandHandlerType { get; set; } - public Func Invoker { get; set; } + /// Gets or sets the closed generic handler interface type for the command. + public Type CommandHandlerType { get; set; } = default!; + + /// Gets or sets the compiled delegate that invokes HandleAsync on the handler. + public Func Invoker { get; set; } = default!; } private const string NameOfExecuteCommand = nameof( @@ -129,26 +165,42 @@ private class CommandExecutionDetails IExecutionResult, ICommand >.HandleAsync); + + /// + /// Gets the for the given command type, optionally retrieving + /// from cache if expression caching is enabled. + /// + /// The runtime type of the command being dispatched. + /// The execution details containing the handler type and compiled invoker delegate. private CommandExecutionDetails GetCommandExecutionDetails(Type commandType) { + // When caching is enabled, cache the compiled expression to avoid repeated reflection/compilation if (_cachingOptions.CachingEnabled && _cachingOptions.CacheDynamicallyCompiledExpressions) { var cachingFactory = _serviceProvider.GetService>(); Guard.Against(cachingFactory == null, "We could not properly inject the caching factory: 'ICommonFactory>' into the CommandBus"); - _cacheService = cachingFactory.Create(ExpressionCachingStrategy.Default); + _cacheService = cachingFactory!.Create(ExpressionCachingStrategy.Default); return _cacheService.GetOrCreate(CacheKey.With(GetType(), commandType.GetCacheKey()), () => this.BuildCommandDetails(commandType)); } return this.BuildCommandDetails(commandType); } + /// + /// Builds the by reflecting over the command type to determine + /// the handler interface and compiling a delegate for HandleAsync. + /// + /// The runtime type of the command being dispatched. + /// A new instance. private CommandExecutionDetails BuildCommandDetails(Type commandType) { + // Find the ICommand interface to extract the result type var commandInterfaceType = commandType .GetTypeInfo() .GetInterfaces() .Single(i => i.GetTypeInfo().IsGenericType && i.GetGenericTypeDefinition() == typeof(ICommand<>)); var commandTypes = commandInterfaceType.GetTypeInfo().GetGenericArguments(); + // Construct the closed generic ICommandHandler type var commandHandlerType = typeof(ICommandHandler<,>) .MakeGenericType(commandTypes[0], commandType); @@ -157,6 +209,7 @@ private CommandExecutionDetails BuildCommandDetails(Type commandType) commandType.PrettyPrint(), commandHandlerType.PrettyPrint()); + // Compile a strongly-typed delegate for the handler's HandleAsync method var invokeExecuteAsync = ReflectionHelper.CompileMethodInvocation>( commandHandlerType, NameOfExecuteCommand); diff --git a/Src/RCommon.ApplicationServices/Commands/ICommandBus.cs b/Src/RCommon.ApplicationServices/Commands/ICommandBus.cs index bddd6257..16a13f0c 100644 --- a/Src/RCommon.ApplicationServices/Commands/ICommandBus.cs +++ b/Src/RCommon.ApplicationServices/Commands/ICommandBus.cs @@ -28,9 +28,23 @@ namespace RCommon.ApplicationServices.Commands { + /// + /// Defines the contract for a command bus that dispatches commands to their corresponding handlers. + /// + /// + /// Commands represent intent to change state. The bus resolves the appropriate + /// and returns an . + /// public interface ICommandBus { - Task DispatchCommandAsync(ICommand command, CancellationToken cancellationToken = default) + /// + /// Dispatches a command to its registered handler and returns the execution result. + /// + /// The type of execution result returned by the handler. + /// The command to dispatch. + /// Optional token to cancel the operation. + /// The execution result produced by the command handler. + Task DispatchCommandAsync(ICommand command, CancellationToken cancellationToken = default) where TResult : IExecutionResult; } } diff --git a/Src/RCommon.ApplicationServices/Commands/ICommandHandler.cs b/Src/RCommon.ApplicationServices/Commands/ICommandHandler.cs index 28bd7197..9855275c 100644 --- a/Src/RCommon.ApplicationServices/Commands/ICommandHandler.cs +++ b/Src/RCommon.ApplicationServices/Commands/ICommandHandler.cs @@ -9,6 +9,9 @@ namespace RCommon.ApplicationServices.Commands { + /// + /// Non-generic marker interface for all command handlers. Used for service resolution via reflection. + /// public interface ICommandHandler { } @@ -18,6 +21,12 @@ public interface ICommandHandler public interface ICommandHandler : ICommandHandler where TCommand: ICommand { + /// + /// Handles the specified command asynchronously. + /// + /// The command to handle. + /// Token to observe for cancellation. + /// A task representing the asynchronous operation. Task HandleAsync(TCommand command, CancellationToken cancellationToken); } @@ -28,6 +37,12 @@ public interface ICommandHandler : ICommandHandler where TCommand : ICommand where TResult : IExecutionResult { + /// + /// Handles the specified command asynchronously and returns an execution result. + /// + /// The command to handle. + /// Token to observe for cancellation. + /// The execution result produced by handling the command. Task HandleAsync(TCommand command, CancellationToken cancellationToken); } } diff --git a/Src/RCommon.ApplicationServices/Commands/NoCommandHandlersException.cs b/Src/RCommon.ApplicationServices/Commands/NoCommandHandlersException.cs index 9327b1b3..1a21fc1e 100644 --- a/Src/RCommon.ApplicationServices/Commands/NoCommandHandlersException.cs +++ b/Src/RCommon.ApplicationServices/Commands/NoCommandHandlersException.cs @@ -25,8 +25,19 @@ namespace RCommon.ApplicationServices.Commands { + /// + /// Exception thrown when no command handlers are registered for a given command type. + /// + /// + /// This exception is raised by when it cannot resolve any + /// implementation for the dispatched command. + /// public class NoCommandHandlersException : Exception { + /// + /// Initializes a new instance of with the specified error message. + /// + /// A message describing which command type has no registered handlers. public NoCommandHandlersException(string message) : base(message) { } diff --git a/Src/RCommon.ApplicationServices/CqrsBuilder.cs b/Src/RCommon.ApplicationServices/CqrsBuilder.cs index 0af33a80..8afde1f5 100644 --- a/Src/RCommon.ApplicationServices/CqrsBuilder.cs +++ b/Src/RCommon.ApplicationServices/CqrsBuilder.cs @@ -10,21 +10,34 @@ namespace RCommon.ApplicationServices { + /// + /// Default implementation of that registers the core CQRS services + /// ( and ) into the dependency injection container. + /// public class CqrsBuilder : ICqrsBuilder { + /// + /// Initializes a new instance of and registers the default CQRS services. + /// + /// The RCommon builder providing access to the service collection. public CqrsBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers the default and as transient services. + /// + /// The service collection to register into. protected void RegisterServices(IServiceCollection services) { services.AddTransient(); services.AddTransient(); - + } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs b/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs index 941f98dd..33e5e0c2 100644 --- a/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs +++ b/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs @@ -37,8 +37,18 @@ namespace RCommon { + /// + /// Extension methods for and that provide + /// fluent registration of CQRS infrastructure, command handlers, and query handlers. + /// public static class CqrsBuilderExtensions { + /// + /// Adds CQRS support using the specified implementation with default configuration. + /// + /// The implementation type to use. + /// The RCommon builder. + /// The for further chaining. public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder) where T : ICqrsBuilder { @@ -46,16 +56,30 @@ public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder) return WithCQRS(builder, x => { }); } + /// + /// Adds CQRS support using the specified implementation and applies additional configuration. + /// + /// The implementation type to use. + /// The RCommon builder. + /// A delegate to configure the CQRS builder (e.g., register handlers). + /// The for further chaining. public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder, Action actions) where T : ICqrsBuilder { - // Event Handling Configurations - var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Instantiate the CQRS builder implementation, which registers core bus services in its constructor + var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(cqrsBuilder); return builder; } + /// + /// Registers a query handler as a transient service for the specified query and result types. + /// + /// The query handler implementation type. + /// The query type. + /// The query result type. + /// The CQRS builder. public static void AddQueryHandler(this ICqrsBuilder builder) where TQueryHandler : class, IQueryHandler where TQuery : IQuery @@ -63,6 +87,14 @@ public static void AddQueryHandler(this ICqrsBui builder.Services.AddTransient, TQueryHandler>(); } + /// + /// Registers a query handler as a transient service. This is an alias for + /// with a query-first type parameter order. + /// + /// The query type. + /// The query handler implementation type. + /// The query result type. + /// The CQRS builder. public static void AddQuery(this ICqrsBuilder builder) where TQueryHandler : class, IQueryHandler where TQuery : IQuery @@ -70,6 +102,13 @@ public static void AddQuery(this ICqrsBuilder bu builder.Services.AddTransient, TQueryHandler>(); } + /// + /// Registers a command handler as a transient service for the specified command and result types. + /// + /// The command handler implementation type. + /// The command type. + /// The execution result type. + /// The CQRS builder. public static void AddCommandHandler(this ICqrsBuilder builder) where TCommandHandler : class, ICommandHandler where TCommand : ICommand @@ -78,6 +117,14 @@ public static void AddCommandHandler(this IC builder.Services.AddTransient, TCommandHandler>(); } + /// + /// Registers a command handler as a transient service. This is an alias for + /// with a command-first type parameter order. + /// + /// The command type. + /// The command handler implementation type. + /// The execution result type. + /// The CQRS builder. public static void AddCommand(this ICqrsBuilder builder) where TCommandHandler : class, ICommandHandler where TCommand : ICommand @@ -86,27 +133,51 @@ public static void AddCommand(this ICqrsBuil builder.Services.AddTransient, TCommandHandler>(); } - public static void AddCommandHandlers(this ICqrsBuilder builder, Assembly fromAssembly, Predicate predicate = null) + /// + /// Scans the specified assembly for implementations + /// and registers them as transient services. + /// + /// The CQRS builder. + /// The assembly to scan for command handler types. + /// An optional predicate to filter which handler types to register. + /// + /// Types whose constructors accept an parameter are excluded + /// to avoid registering decorator types as base handlers. + /// + public static void AddCommandHandlers(this ICqrsBuilder builder, Assembly fromAssembly, Predicate? predicate = null) { predicate = predicate ?? (t => true); var commandHandlerTypes = fromAssembly .GetTypes() .Where(t => t.GetTypeInfo().GetInterfaces().Any(IsCommandHandlerInterface)) + // Exclude types that accept a command handler in their constructor (likely decorators) .Where(t => !t.HasConstructorParameterOfType(IsCommandHandlerInterface)) .Where(t => predicate(t)); AddCommandHandlers(builder, commandHandlerTypes); } + /// + /// Registers the specified command handler types as transient services. + /// + /// The CQRS builder. + /// The command handler types to register. public static void AddCommandHandlers(this ICqrsBuilder builder, params Type[] commandHandlerTypes) { AddCommandHandlers(builder, (IEnumerable)commandHandlerTypes); } + /// + /// Registers the specified command handler types as transient services. + /// + /// The CQRS builder. + /// The command handler types to register. + /// Thrown when a type does not implement . public static void AddCommandHandlers(this ICqrsBuilder builder, IEnumerable commandHandlerTypes) { foreach (var commandHandlerType in commandHandlerTypes) { var t = commandHandlerType; + // Skip abstract types as they cannot be instantiated if (t.GetTypeInfo().IsAbstract) continue; var handlesCommandTypes = t .GetTypeInfo() @@ -118,6 +189,7 @@ public static void AddCommandHandlers(this ICqrsBuilder builder, IEnumerable).PrettyPrint()}'"); } + // Register the handler type for each command handler interface it implements foreach (var handlesCommandType in handlesCommandTypes) { builder.Services.AddTransient(handlesCommandType, t); @@ -125,33 +197,60 @@ public static void AddCommandHandlers(this ICqrsBuilder builder, IEnumerable + /// Determines whether the specified type is a closed generic form of . + /// private static bool IsCommandHandlerInterface(this Type type) { return type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(ICommandHandler<,>); } + /// + /// Registers the specified query handler types as transient services. + /// + /// The CQRS builder. + /// The query handler types to register. public static void AddQueryHandlers(this ICqrsBuilder builder, params Type[] queryHandlerTypes) { AddQueryHandlers(builder, (IEnumerable)queryHandlerTypes); } + /// + /// Scans the specified assembly for implementations + /// and registers them as transient services. + /// + /// The CQRS builder. + /// The assembly to scan for query handler types. + /// An optional predicate to filter which handler types to register. + /// + /// Types whose constructors accept an parameter are excluded + /// to avoid registering decorator types as base handlers. + /// public static void AddQueryHandlers(this ICqrsBuilder builder, Assembly fromAssembly, - Predicate predicate = null) + Predicate? predicate = null) { predicate = predicate ?? (t => true); var subscribeSynchronousToTypes = fromAssembly .GetTypes() .Where(t => t.GetTypeInfo().GetInterfaces().Any(IsQueryHandlerInterface)) + // Exclude types that accept a query handler in their constructor (likely decorators) .Where(t => !t.HasConstructorParameterOfType(IsQueryHandlerInterface)) .Where(t => predicate(t)); AddQueryHandlers(builder, subscribeSynchronousToTypes); } + /// + /// Registers the specified query handler types as transient services. + /// + /// The CQRS builder. + /// The query handler types to register. + /// Thrown when a type does not implement . public static void AddQueryHandlers(this ICqrsBuilder builder, IEnumerable queryHandlerTypes) { foreach (var queryHandlerType in queryHandlerTypes) { var t = queryHandlerType; + // Skip abstract types as they cannot be instantiated if (t.GetTypeInfo().IsAbstract) continue; var queryHandlerInterfaces = t .GetTypeInfo() @@ -163,6 +262,7 @@ public static void AddQueryHandlers(this ICqrsBuilder builder, IEnumerable throw new ArgumentException($"Type '{t.PrettyPrint()}' is not an '{typeof(IQueryHandler<,>).PrettyPrint()}'"); } + // Register the handler type for each query handler interface it implements foreach (var queryHandlerInterface in queryHandlerInterfaces) { builder.Services.AddTransient(queryHandlerInterface, t); @@ -170,6 +270,9 @@ public static void AddQueryHandlers(this ICqrsBuilder builder, IEnumerable } } + /// + /// Determines whether the specified type is a closed generic form of . + /// private static bool IsQueryHandlerInterface(this Type type) { return type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(IQueryHandler<,>); diff --git a/Src/RCommon.ApplicationServices/CqrsCachingOptions.cs b/Src/RCommon.ApplicationServices/CqrsCachingOptions.cs index 69c8878e..1c6bb330 100644 --- a/Src/RCommon.ApplicationServices/CqrsCachingOptions.cs +++ b/Src/RCommon.ApplicationServices/CqrsCachingOptions.cs @@ -6,13 +6,27 @@ namespace RCommon.ApplicationServices { + /// + /// Configuration options for caching behavior within the CQRS pipeline. + /// + /// + /// When is enabled, the command and query buses may cache + /// resolved handler metadata to reduce reflection overhead on subsequent dispatches. + /// Defaults to false. + /// public class CqrsCachingOptions { + /// + /// Initializes a new instance of with caching disabled. + /// public CqrsCachingOptions() { this.UseCacheForHandlers = false; } + /// + /// Gets or sets a value indicating whether resolved handler metadata should be cached. + /// public bool UseCacheForHandlers { get; set; } } } diff --git a/Src/RCommon.ApplicationServices/CqrsValidationOptions.cs b/Src/RCommon.ApplicationServices/CqrsValidationOptions.cs index ef14d8b3..b5c069ec 100644 --- a/Src/RCommon.ApplicationServices/CqrsValidationOptions.cs +++ b/Src/RCommon.ApplicationServices/CqrsValidationOptions.cs @@ -7,15 +7,33 @@ namespace RCommon.ApplicationServices { + /// + /// Configuration options that control whether automatic validation is applied to CQRS commands and queries. + /// + /// + /// When enabled, the and will invoke + /// before dispatching to handlers. + /// Both options default to false. + /// public class CqrsValidationOptions { + /// + /// Initializes a new instance of with validation disabled for both commands and queries. + /// public CqrsValidationOptions() { ValidateQueries = false; ValidateCommands = false; } + /// + /// Gets or sets a value indicating whether queries should be validated before dispatch. + /// public bool ValidateQueries { get; set; } + + /// + /// Gets or sets a value indicating whether commands should be validated before dispatch. + /// public bool ValidateCommands { get; set; } } } diff --git a/Src/RCommon.ApplicationServices/ICqrsBuilder.cs b/Src/RCommon.ApplicationServices/ICqrsBuilder.cs index 3eba4b2b..4a4404c1 100644 --- a/Src/RCommon.ApplicationServices/ICqrsBuilder.cs +++ b/Src/RCommon.ApplicationServices/ICqrsBuilder.cs @@ -2,8 +2,18 @@ namespace RCommon.ApplicationServices { + /// + /// Defines the contract for a builder that configures CQRS (Command Query Responsibility Segregation) services. + /// + /// + /// Implementations register command and query bus infrastructure into the DI container. + /// Use extension methods on this interface (e.g., AddCommandHandler, AddQueryHandler) to register handlers. + /// public interface ICqrsBuilder { + /// + /// Gets the used to register CQRS-related services. + /// IServiceCollection Services { get; } } } \ No newline at end of file diff --git a/Src/RCommon.ApplicationServices/IValidationBuilder.cs b/Src/RCommon.ApplicationServices/IValidationBuilder.cs index 3fe67def..a7d1b5d7 100644 --- a/Src/RCommon.ApplicationServices/IValidationBuilder.cs +++ b/Src/RCommon.ApplicationServices/IValidationBuilder.cs @@ -7,8 +7,19 @@ namespace RCommon.ApplicationServices { + /// + /// Defines the contract for a builder that configures validation services. + /// + /// + /// Implementations register and related services + /// into the DI container. Use to integrate + /// validation with the CQRS pipeline. + /// public interface IValidationBuilder { + /// + /// Gets the used to register validation-related services. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.ApplicationServices/InvalidCacheException.cs b/Src/RCommon.ApplicationServices/InvalidCacheException.cs index 90281342..a08b20fa 100644 --- a/Src/RCommon.ApplicationServices/InvalidCacheException.cs +++ b/Src/RCommon.ApplicationServices/InvalidCacheException.cs @@ -6,11 +6,23 @@ namespace RCommon.ApplicationServices { + /// + /// Exception thrown when the caching infrastructure is not properly configured or available. + /// + /// + /// This exception is raised by or + /// when expression caching is enabled but the required ICommonFactory<ExpressionCachingStrategy, ICacheService> + /// cannot be resolved from the service provider. + /// public class InvalidCacheException : GeneralException { + /// + /// Initializes a new instance of with the specified error message. + /// + /// A message describing the caching configuration problem. public InvalidCacheException(string message):base(message) { - + } } } diff --git a/Src/RCommon.ApplicationServices/Queries/IQueryBus.cs b/Src/RCommon.ApplicationServices/Queries/IQueryBus.cs index dac776bd..0a9f7057 100644 --- a/Src/RCommon.ApplicationServices/Queries/IQueryBus.cs +++ b/Src/RCommon.ApplicationServices/Queries/IQueryBus.cs @@ -8,8 +8,22 @@ namespace RCommon.ApplicationServices.Queries { + /// + /// Defines the contract for a query bus that dispatches queries to their corresponding handlers. + /// + /// + /// Queries represent requests for data that do not modify state. The bus resolves the appropriate + /// and returns the result. + /// public interface IQueryBus { + /// + /// Dispatches a query to its registered handler and returns the result. + /// + /// The type of result returned by the query handler. + /// The query to dispatch. + /// Optional token to cancel the operation. + /// The result produced by the query handler. Task DispatchQueryAsync(IQuery query, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.ApplicationServices/Queries/IQueryHandler.cs b/Src/RCommon.ApplicationServices/Queries/IQueryHandler.cs index a1cde1a7..58f7609a 100644 --- a/Src/RCommon.ApplicationServices/Queries/IQueryHandler.cs +++ b/Src/RCommon.ApplicationServices/Queries/IQueryHandler.cs @@ -8,13 +8,27 @@ namespace RCommon.ApplicationServices.Queries { + /// + /// Non-generic marker interface for all query handlers. Used for service resolution via reflection. + /// public interface IQueryHandler { } + /// + /// Defines the contract for a handler that processes a specific query type and returns a result. + /// + /// The query type to handle. + /// The type of result produced by handling the query. public interface IQueryHandler : IQueryHandler where TQuery : IQuery { + /// + /// Handles the specified query asynchronously and returns the result. + /// + /// The query to handle. + /// Token to observe for cancellation. + /// The result produced by handling the query. Task HandleAsync(TQuery query, CancellationToken cancellationToken); } } diff --git a/Src/RCommon.ApplicationServices/Queries/QueryBus.cs b/Src/RCommon.ApplicationServices/Queries/QueryBus.cs index c55bff38..9cbf09e7 100644 --- a/Src/RCommon.ApplicationServices/Queries/QueryBus.cs +++ b/Src/RCommon.ApplicationServices/Queries/QueryBus.cs @@ -36,12 +36,27 @@ namespace RCommon.ApplicationServices.Queries { + /// + /// Default implementation of that dispatches queries to their registered handlers + /// using the dependency injection container. + /// + /// + /// The query bus resolves the appropriate from the service provider, + /// optionally validates the query via , and invokes the handler using + /// a dynamically compiled delegate. Compiled handler delegates can optionally be cached for improved performance. + /// public class QueryBus : IQueryBus { + /// + /// Holds the resolved handler type and the compiled delegate used to invoke it. + /// private class HandlerFuncMapping { - public Type QueryHandlerType { get; set; } - public Func HandlerFunc { get; set; } + /// Gets or sets the closed generic handler interface type for the query. + public Type QueryHandlerType { get; set; } = default!; + + /// Gets or sets the compiled delegate that invokes HandleAsync on the handler. + public Func HandlerFunc { get; set; } = default!; } private readonly ILogger _logger; @@ -49,8 +64,16 @@ private class HandlerFuncMapping private readonly IValidationService _validationService; private readonly IOptions _validationOptions; private readonly CachingOptions _cachingOptions; - private ICacheService _cacheService; + private ICacheService? _cacheService; + /// + /// Initializes a new instance of . + /// + /// Logger for tracing query execution. + /// Service provider used to resolve query handlers. + /// Service used to validate queries before execution. + /// Options controlling whether query validation is enabled. + /// Options controlling whether dynamically compiled expressions are cached. public QueryBus(ILogger logger, IServiceProvider serviceProvider, IValidationService validationService, IOptions validationOptions, IOptions cachingOptions) { @@ -61,8 +84,10 @@ public QueryBus(ILogger logger, IServiceProvider serviceProvider, IVal _cachingOptions = cachingOptions.Value; } + /// public async Task DispatchQueryAsync(IQuery query, CancellationToken cancellationToken = default) { + // Validate the query if validation is configured for queries if (_validationOptions.Value != null && _validationOptions.Value.ValidateQueries) { // TODO: Would be nice to be able to take validation outcome and put in IQuery. Need some casting magic @@ -82,32 +107,51 @@ public async Task DispatchQueryAsync(IQuery query, Ca queryHandler.GetType().PrettyPrint()); } + // Invoke the handler via the dynamically compiled delegate var task = (Task)handlerFunc.HandlerFunc(queryHandler, query, cancellationToken); return await task.ConfigureAwait(false); } + /// + /// Gets the for the given query type, optionally retrieving + /// from cache if expression caching is enabled. + /// + /// The runtime type of the query being dispatched. + /// The handler function mapping containing the handler type and compiled invoker delegate. private HandlerFuncMapping GetHandlerFuncMapping(Type queryType) { + // When caching is enabled, cache the compiled expression to avoid repeated reflection/compilation if (_cachingOptions.CachingEnabled && _cachingOptions.CacheDynamicallyCompiledExpressions) { var cachingFactory = _serviceProvider.GetService>(); Guard.Against(cachingFactory == null, "We could not properly inject the caching factory: 'ICommonFactory>' into the QueryBus"); - _cacheService = cachingFactory.Create(ExpressionCachingStrategy.Default); - return _cacheService.GetOrCreate(CacheKey.With(GetType(), queryType.GetCacheKey()), + _cacheService = cachingFactory!.Create(ExpressionCachingStrategy.Default); + return _cacheService.GetOrCreate(CacheKey.With(GetType(), queryType.GetCacheKey()), () => this.BuildHandlerFuncMapping(queryType)); } return this.BuildHandlerFuncMapping(queryType); - + } + /// + /// Builds the by reflecting over the query type to determine + /// the handler interface and compiling a delegate for HandleAsync. + /// + /// The runtime type of the query being dispatched. + /// A new instance. private HandlerFuncMapping BuildHandlerFuncMapping(Type queryType) { + // Find the IQuery interface to extract the result type var queryInterfaceType = queryType .GetTypeInfo() .GetInterfaces() .Single(i => i.GetTypeInfo().IsGenericType && i.GetGenericTypeDefinition() == typeof(IQuery<>)); + + // Construct the closed generic IQueryHandler type var queryHandlerType = typeof(IQueryHandler<,>).MakeGenericType(queryType, queryInterfaceType.GetTypeInfo().GetGenericArguments()[0]); + + // Compile a strongly-typed delegate for the handler's HandleAsync method var invokeExecuteQueryAsync = ReflectionHelper.CompileMethodInvocation>( queryHandlerType, "HandleAsync", diff --git a/Src/RCommon.ApplicationServices/RCommon.ApplicationServices.csproj b/Src/RCommon.ApplicationServices/RCommon.ApplicationServices.csproj index 4f303a75..ebc47211 100644 --- a/Src/RCommon.ApplicationServices/RCommon.ApplicationServices.csproj +++ b/Src/RCommon.ApplicationServices/RCommon.ApplicationServices.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.ApplicationServices https://rcommon.com diff --git a/Src/RCommon.ApplicationServices/README.md b/Src/RCommon.ApplicationServices/README.md index 88074100..2c74cc78 100644 --- a/Src/RCommon.ApplicationServices/README.md +++ b/Src/RCommon.ApplicationServices/README.md @@ -1,3 +1,107 @@ # RCommon.ApplicationServices -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides a CQRS (Command Query Responsibility Segregation) implementation with dedicated command and query buses, handler registration, and optional validation integration for the RCommon framework. + +## Features + +- **Command Bus** -- dispatches commands to a single registered `ICommandHandler` and returns an `IExecutionResult` +- **Query Bus** -- dispatches queries to a single registered `IQueryHandler` and returns a typed result +- **Validation pipeline** -- optionally validates commands and/or queries before handler execution via `IValidationService` +- **Handler registration** -- register handlers individually or scan assemblies with automatic decorator exclusion +- **Expression caching** -- dynamically compiled handler delegates can be cached for improved dispatch performance +- **Fluent builder API** -- integrates with the `AddRCommon()` builder pattern for clean DI configuration + +## Installation + +```shell +dotnet add package RCommon.ApplicationServices +``` + +## Usage + +```csharp +using RCommon; +using RCommon.ApplicationServices; + +// Configure CQRS in your DI setup +services.AddRCommon(config => +{ + config.WithCQRS(cqrs => + { + // Register handlers individually + cqrs.AddCommandHandler(); + cqrs.AddQueryHandler(); + + // Or scan an assembly for all handlers + cqrs.AddCommandHandlers(typeof(CreateOrderHandler).Assembly); + cqrs.AddQueryHandlers(typeof(GetOrderHandler).Assembly); + }); +}); + +// Dispatch a command from your application layer +public class OrderService +{ + private readonly ICommandBus _commandBus; + private readonly IQueryBus _queryBus; + + public OrderService(ICommandBus commandBus, IQueryBus queryBus) + { + _commandBus = commandBus; + _queryBus = queryBus; + } + + public async Task CreateOrderAsync(CreateOrderCommand command) + { + return await _commandBus.DispatchCommandAsync(command); + } + + public async Task GetOrderAsync(GetOrderQuery query) + { + return await _queryBus.DispatchQueryAsync(query); + } +} +``` + +### Enabling Validation + +```csharp +services.AddRCommon(config => +{ + config.WithValidation(validation => + { + validation.UseWithCqrs(options => + { + options.ValidateCommands = true; + options.ValidateQueries = true; + }); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `ICommandBus` | Dispatches commands to their registered handler and returns an `IExecutionResult` | +| `IQueryBus` | Dispatches queries to their registered handler and returns a typed result | +| `ICommandHandler` | Handles a specific command type and produces an execution result | +| `IQueryHandler` | Handles a specific query type and produces a result | +| `IValidationService` | Validates objects before dispatch; integrates with the CQRS pipeline | +| `ValidationOutcome` | Contains a list of `ValidationFault` errors produced by validation | +| `ValidationFault` | Describes a single validation failure with property name, message, and severity | +| `CqrsValidationOptions` | Controls whether commands and/or queries are validated before dispatch | +| `CqrsBuilder` | Default `ICqrsBuilder` implementation that registers `CommandBus` and `QueryBus` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure +- [RCommon.FluentValidation](https://www.nuget.org/packages/RCommon.FluentValidation) - FluentValidation-based `IValidationProvider` for CQRS pipeline integration +- [RCommon.Models](https://www.nuget.org/packages/RCommon.Models) - `ICommand`, `IQuery`, and `IExecutionResult` model contracts + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.ApplicationServices/Validation/IValidationProvider.cs b/Src/RCommon.ApplicationServices/Validation/IValidationProvider.cs index 1a8a2846..3c36872d 100644 --- a/Src/RCommon.ApplicationServices/Validation/IValidationProvider.cs +++ b/Src/RCommon.ApplicationServices/Validation/IValidationProvider.cs @@ -7,10 +7,26 @@ namespace RCommon.ApplicationServices.Validation { + /// + /// Defines the contract for a validation provider that performs validation against a target object. + /// + /// + /// Implementations bridge to a specific validation library (e.g., FluentValidation). + /// The resolves an from the DI container + /// to perform the actual validation logic. + /// public interface IValidationProvider { + /// + /// Validates the specified target object asynchronously. + /// + /// The type of the object to validate. + /// The object to validate. + /// If true, a is thrown when validation fails. + /// Optional token to cancel the operation. + /// A containing any validation faults. Task ValidateAsync(T target, bool throwOnFaults, CancellationToken cancellationToken = default) where T : class; - + } } diff --git a/Src/RCommon.ApplicationServices/Validation/IValidationService.cs b/Src/RCommon.ApplicationServices/Validation/IValidationService.cs index b3dc5a78..b257c59d 100644 --- a/Src/RCommon.ApplicationServices/Validation/IValidationService.cs +++ b/Src/RCommon.ApplicationServices/Validation/IValidationService.cs @@ -3,8 +3,24 @@ namespace RCommon.ApplicationServices.Validation { + /// + /// Defines the contract for a validation service that orchestrates object validation. + /// + /// + /// The validation service acts as a facade over , creating a DI scope + /// and delegating to the registered provider. It is used by + /// and when CQRS validation is enabled. + /// public interface IValidationService { + /// + /// Validates the specified target object asynchronously. + /// + /// The type of the object to validate. + /// The object to validate. + /// If true, a is thrown when validation fails. Defaults to false. + /// Optional token to cancel the operation. + /// A containing any validation faults. Task ValidateAsync(T target, bool throwOnFaults = false, CancellationToken cancellationToken = default) where T : class; } } \ No newline at end of file diff --git a/Src/RCommon.ApplicationServices/Validation/ValidationException.cs b/Src/RCommon.ApplicationServices/Validation/ValidationException.cs index fbbb82dc..56753306 100644 --- a/Src/RCommon.ApplicationServices/Validation/ValidationException.cs +++ b/Src/RCommon.ApplicationServices/Validation/ValidationException.cs @@ -35,30 +35,30 @@ public class ValidationException : Exception public IEnumerable Errors { get; private set; } /// - /// Creates a new ValidationException + /// Creates a new with a message and no errors. /// - /// + /// The exception message. public ValidationException(string message) : this(message, Enumerable.Empty()) { } /// - /// Creates a new ValidationException + /// Creates a new with a message and a collection of validation errors. /// - /// - /// + /// The exception message. + /// The collection of instances representing the validation failures. public ValidationException(string message, IEnumerable errors) : base(message) { Errors = errors; } /// - /// Creates a new ValidationException + /// Creates a new with a message, validation errors, and an option to append the default error summary. /// - /// - /// - /// appends default validation error message to message + /// The exception message. + /// The collection of instances representing the validation failures. + /// If true, appends the formatted validation error summary to . public ValidationException(string message, IEnumerable errors, bool appendDefaultMessage) : base(appendDefaultMessage ? $"{message} {BuildErrorMessage(errors)}" : message) { @@ -66,14 +66,20 @@ public ValidationException(string message, IEnumerable errors, } /// - /// Creates a new ValidationException + /// Creates a new from a collection of validation errors, using a generated error summary as the message. /// - /// + /// The collection of instances representing the validation failures. public ValidationException(IEnumerable errors) : base(BuildErrorMessage(errors)) { Errors = errors; } + /// + /// Builds a human-readable error message from a collection of instances, + /// listing each property name, error message, and severity. + /// + /// The validation faults to format. + /// A formatted string summarizing all validation failures. private static string BuildErrorMessage(IEnumerable errors) { var arr = errors.Select(x => $"{Environment.NewLine} -- {x.PropertyName}: {x.ErrorMessage} Severity: {x.Severity.ToString()}"); diff --git a/Src/RCommon.ApplicationServices/Validation/ValidationFault.cs b/Src/RCommon.ApplicationServices/Validation/ValidationFault.cs index dbbfd669..b87e876f 100644 --- a/Src/RCommon.ApplicationServices/Validation/ValidationFault.cs +++ b/Src/RCommon.ApplicationServices/Validation/ValidationFault.cs @@ -39,15 +39,20 @@ public ValidationFault() /// /// Creates a new validation failure. /// + /// The name of the property that failed validation. + /// The error message describing the failure. public ValidationFault(string propertyName, string errorMessage) : this(propertyName, errorMessage, null) { } /// - /// Creates a new ValidationFailure. + /// Creates a new validation failure. /// - public ValidationFault(string propertyName, string errorMessage, object attemptedValue) + /// The name of the property that failed validation. + /// The error message describing the failure. + /// The value that was rejected by the validation rule. + public ValidationFault(string propertyName, string errorMessage, object? attemptedValue) { PropertyName = propertyName; ErrorMessage = errorMessage; @@ -57,22 +62,22 @@ public ValidationFault(string propertyName, string errorMessage, object attempte /// /// The name of the property. /// - public string PropertyName { get; set; } + public string? PropertyName { get; set; } /// /// The error message /// - public string ErrorMessage { get; set; } + public string? ErrorMessage { get; set; } /// /// The property value that caused the failure. /// - public object AttemptedValue { get; set; } + public object? AttemptedValue { get; set; } /// /// Custom state associated with the failure. /// - public object CustomState { get; set; } + public object? CustomState { get; set; } /// /// Custom severity level associated with the failure. @@ -82,17 +87,17 @@ public ValidationFault(string propertyName, string errorMessage, object attempte /// /// Gets or sets the error code. /// - public string ErrorCode { get; set; } + public string? ErrorCode { get; set; } /// /// Gets or sets the formatted message placeholder values. /// - public Dictionary FormattedMessagePlaceholderValues { get; set; } + public Dictionary? FormattedMessagePlaceholderValues { get; set; } /// /// Creates a textual representation of the failure. /// - public override string ToString() + public override string? ToString() { return ErrorMessage; } diff --git a/Src/RCommon.ApplicationServices/Validation/ValidationOutcome.cs b/Src/RCommon.ApplicationServices/Validation/ValidationOutcome.cs index 3d65ce5b..d616f7ac 100644 --- a/Src/RCommon.ApplicationServices/Validation/ValidationOutcome.cs +++ b/Src/RCommon.ApplicationServices/Validation/ValidationOutcome.cs @@ -57,7 +57,7 @@ public List Errors /// /// The RuleSets that were executed during the validation run. /// - public string[] RuleSetsExecuted { get; set; } + public string[]? RuleSetsExecuted { get; set; } /// /// Creates a new ValidationResult @@ -81,13 +81,13 @@ public ValidationOutcome(IEnumerable failures) } /// - /// Creates a new ValidationResult by combining several other ValidationResults. + /// Creates a new by combining the errors and rule sets from several other validation results. /// - /// + /// The validation outcomes to merge into a single result. public ValidationOutcome(IEnumerable otherResults) { _errors = otherResults.SelectMany(x => x.Errors).ToList(); - RuleSetsExecuted = otherResults.Where(x => x.RuleSetsExecuted != null).SelectMany(x => x.RuleSetsExecuted).Distinct().ToArray(); + RuleSetsExecuted = otherResults.Where(x => x.RuleSetsExecuted != null).SelectMany(x => x.RuleSetsExecuted!).Distinct().ToArray(); } internal ValidationOutcome(List errors) @@ -123,10 +123,10 @@ public string ToString(string separator) public IDictionary ToDictionary() { return Errors - .GroupBy(x => x.PropertyName) + .GroupBy(x => x.PropertyName ?? string.Empty) .ToDictionary( g => g.Key, - g => g.Select(x => x.ErrorMessage).ToArray() + g => g.Select(x => x.ErrorMessage ?? string.Empty).ToArray() ); } } diff --git a/Src/RCommon.ApplicationServices/Validation/ValidationService.cs b/Src/RCommon.ApplicationServices/Validation/ValidationService.cs index f3ac0b3d..ab5c785b 100644 --- a/Src/RCommon.ApplicationServices/Validation/ValidationService.cs +++ b/Src/RCommon.ApplicationServices/Validation/ValidationService.cs @@ -8,23 +8,37 @@ namespace RCommon.ApplicationServices.Validation { + /// + /// Default implementation of that delegates validation + /// to a scoped . + /// + /// + /// A new DI scope is created for each validation call to ensure that scoped validation + /// providers (and their dependencies) are properly resolved and disposed. + /// public class ValidationService : IValidationService { private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The root service provider used to create scoped validation providers. public ValidationService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } + /// public async Task ValidateAsync(T target, bool throwOnFaults = false, CancellationToken cancellationToken = default) where T : class { + // Create a new scope so that scoped IValidationProvider instances are properly resolved using (var scope = _serviceProvider.CreateScope()) { var provider = scope.ServiceProvider.GetService(); - Guard.IsNotNull(provider, nameof(provider)); - var outcome = await provider.ValidateAsync(target, throwOnFaults, cancellationToken); + Guard.IsNotNull(provider!, nameof(provider)); + var outcome = await provider!.ValidateAsync(target, throwOnFaults, cancellationToken); return outcome; } } diff --git a/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs b/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs index 9cdf02c2..df50f837 100644 --- a/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs +++ b/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs @@ -10,26 +10,49 @@ namespace RCommon.ApplicationServices { + /// + /// Extension methods for and that provide + /// fluent registration of validation infrastructure. + /// public static class ValidationBuilderExtensions { + /// + /// Adds validation support using the specified implementation with default configuration. + /// + /// The implementation type to use. + /// The RCommon builder. + /// The for further chaining. public static IRCommonBuilder WithValidation(this IRCommonBuilder builder) where T : IValidationBuilder { return WithValidation(builder, x => { }); } + /// + /// Adds validation support using the specified implementation and applies additional configuration. + /// + /// The implementation type to use. + /// The RCommon builder. + /// A delegate to configure the validation builder (e.g., register validation providers). + /// The for further chaining. public static IRCommonBuilder WithValidation(this IRCommonBuilder builder, Action actions) where T : IValidationBuilder { builder.Services.AddScoped(); - // Event Handling Configurations - var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Instantiate the validation builder implementation, which may register provider-specific services + var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(mediatorConfig); return builder; } + /// + /// Configures the validation builder to integrate with the CQRS pipeline by setting + /// (e.g., enabling command or query validation). + /// + /// The validation builder. + /// A delegate to configure . public static void UseWithCqrs(this IValidationBuilder builder, Action options) { builder.Services.Configure(options); diff --git a/Src/RCommon.Authorization.Web/Filters/AuthorizationHeaderParameterOperationFilter.cs b/Src/RCommon.Authorization.Web/Filters/AuthorizationHeaderParameterOperationFilter.cs index 71a95727..1e2c8442 100644 --- a/Src/RCommon.Authorization.Web/Filters/AuthorizationHeaderParameterOperationFilter.cs +++ b/Src/RCommon.Authorization.Web/Filters/AuthorizationHeaderParameterOperationFilter.cs @@ -13,11 +13,24 @@ namespace RCommon.Authorization.Web.Filters { + /// + /// A Swashbuckle that adds a required "Authorization" header parameter + /// to every Swagger/OpenAPI operation whose action pipeline includes an + /// and does not allow anonymous access. + /// public class AuthorizationHeaderParameterOperationFilter : IOperationFilter { + /// + /// Applies the filter to the given by inspecting the action's + /// filter pipeline for authorization requirements. + /// + /// The OpenAPI operation being processed. + /// The filter context containing API description metadata. public void Apply(OpenApiOperation operation, OperationFilterContext context) { var filterPipeline = context.ApiDescription.ActionDescriptor.FilterDescriptors; + + // Determine whether the action has authorization enforced and whether anonymous access is permitted. var isAuthorized = filterPipeline.Select(filterInfo => filterInfo.Filter).Any(filter => filter is AuthorizeFilter); var allowAnonymous = filterPipeline.Select(filterInfo => filterInfo.Filter).Any(filter => filter is IAllowAnonymousFilter); @@ -30,6 +43,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) operation.Parameters = new List(); #endif + // Add a required Authorization header so that Swagger UI prompts for a token. operation.Parameters.Add(new OpenApiParameter { Name = "Authorization", diff --git a/Src/RCommon.Authorization.Web/Filters/AuthorizeCheckOperationFilter.cs b/Src/RCommon.Authorization.Web/Filters/AuthorizeCheckOperationFilter.cs index a044d8b5..0209ada3 100644 --- a/Src/RCommon.Authorization.Web/Filters/AuthorizeCheckOperationFilter.cs +++ b/Src/RCommon.Authorization.Web/Filters/AuthorizeCheckOperationFilter.cs @@ -13,19 +13,32 @@ namespace RCommon.Authorization.Web.Filters { + /// + /// A Swashbuckle that adds 401/403 response codes and an OAuth2 + /// security requirement to every Swagger/OpenAPI operation decorated with . + /// public class AuthorizeCheckOperationFilter : IOperationFilter { + /// + /// Applies the filter to the given by checking whether the + /// controller or action method is decorated with . + /// If so, 401 and 403 responses are added and an OAuth2 security requirement is attached. + /// + /// The OpenAPI operation being processed. + /// The filter context containing method and controller metadata. public void Apply(OpenApiOperation operation, OperationFilterContext context) { - // Check for authorize attribute - var hasAuthorize = context.MethodInfo.DeclaringType.GetCustomAttributes(true).OfType().Any() || + // Check for authorize attribute on the controller type or the action method itself. + var hasAuthorize = (context.MethodInfo.DeclaringType?.GetCustomAttributes(true).OfType().Any() ?? false) || context.MethodInfo.GetCustomAttributes(true).OfType().Any(); if (!hasAuthorize) return; - operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); - operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); + // Add standard authorization failure responses to the operation. + operation.Responses?.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses?.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); + // Attach an OAuth2 security requirement referencing the "api" scope. #if NET10_0_OR_GREATER var oAuthScheme = new OpenApiSecuritySchemeReference("oauth2"); diff --git a/Src/RCommon.Authorization.Web/RCommon.Authorization.Web.csproj b/Src/RCommon.Authorization.Web/RCommon.Authorization.Web.csproj index 3922c2eb..c77bc9c7 100644 --- a/Src/RCommon.Authorization.Web/RCommon.Authorization.Web.csproj +++ b/Src/RCommon.Authorization.Web/RCommon.Authorization.Web.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Authorization.Web https://rcommon.com diff --git a/Src/RCommon.Authorization.Web/README.md b/Src/RCommon.Authorization.Web/README.md index 6a5fa4d1..08c9cb37 100644 --- a/Src/RCommon.Authorization.Web/README.md +++ b/Src/RCommon.Authorization.Web/README.md @@ -1,3 +1,54 @@ # RCommon.Authorization.Web -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Swagger/OpenAPI operation filters for ASP.NET Core that automatically surface authorization metadata in your API documentation, including required Authorization headers and OAuth2 security requirements. + +## Features + +- Automatically adds an `Authorization` header parameter to Swagger operations protected by `AuthorizeFilter` +- Detects `[Authorize]` attribute on controllers and actions and adds 401/403 response codes to the OpenAPI spec +- Attaches OAuth2 security requirements to authorized operations +- Respects `[AllowAnonymous]` to skip authorization header injection +- Compatible with Swashbuckle.AspNetCore across .NET 8, .NET 9, and .NET 10 + +## Installation + +```shell +dotnet add package RCommon.Authorization.Web +``` + +## Usage + +Register the operation filters when configuring Swagger in your ASP.NET Core application: + +```csharp +using RCommon.Authorization.Web.Filters; + +builder.Services.AddSwaggerGen(options => +{ + // Adds a required Authorization header to operations with AuthorizeFilter + options.OperationFilter(); + + // Adds 401/403 responses and OAuth2 security to operations with [Authorize] + options.OperationFilter(); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `AuthorizationHeaderParameterOperationFilter` | Adds a required `Authorization` header parameter to operations protected by `AuthorizeFilter` | +| `AuthorizeCheckOperationFilter` | Adds 401/403 responses and an OAuth2 security requirement to operations decorated with `[Authorize]` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Security](https://www.nuget.org/packages/RCommon.Security) - Core security abstractions +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Caching/CacheKey.cs b/Src/RCommon.Caching/CacheKey.cs index 666a793c..d455d5a4 100644 --- a/Src/RCommon.Caching/CacheKey.cs +++ b/Src/RCommon.Caching/CacheKey.cs @@ -25,10 +25,27 @@ namespace RCommon.Caching { + /// + /// Represents a strongly-typed cache key with built-in validation and factory methods. + /// + /// + /// Cache keys are validated on construction to ensure they are non-empty and do not exceed + /// characters. Use the static factory + /// methods to create keys from component parts. + /// public class CacheKey { + /// + /// The maximum allowed length for a cache key value. + /// public const int MaxLength = 256; + /// + /// Initializes a new instance of the class. + /// + /// The cache key string value. + /// Thrown when is null or empty. + /// Thrown when exceeds . public CacheKey(string value) { if (string.IsNullOrEmpty(value)) @@ -37,11 +54,23 @@ public CacheKey(string value) throw new ArgumentOutOfRangeException(nameof(value), value, $"Cache keys can maximum be '{MaxLength}' in length"); } + /// + /// Creates a by joining the specified key segments with a hyphen delimiter. + /// + /// The key segments to join. + /// A new composed of the joined segments. public static CacheKey With(params string[] keys) { return new CacheKey(string.Join("-", keys)); } + /// + /// Creates a scoped to the specified owner type, using its cache key + /// representation as a prefix followed by the joined key segments. + /// + /// The type used to scope the cache key. + /// The key segments to join after the type prefix. + /// A new in the format TypeCacheKey:segment1-segment2. public static CacheKey With(Type ownerType, params string[] keys) { return With($"{ownerType.GetCacheKey()}:{string.Join("-", keys)}"); diff --git a/Src/RCommon.Caching/CachingBuilderExtensions.cs b/Src/RCommon.Caching/CachingBuilderExtensions.cs index 53788789..ca5a4637 100644 --- a/Src/RCommon.Caching/CachingBuilderExtensions.cs +++ b/Src/RCommon.Caching/CachingBuilderExtensions.cs @@ -8,34 +8,75 @@ namespace RCommon.Caching { + /// + /// Extension methods on for registering caching infrastructure. + /// public static class CachingBuilderExtensions { + /// + /// Registers an implementation with default configuration. + /// + /// The concrete memory caching builder type to activate. + /// The RCommon builder instance. + /// The same for chaining. public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder) where T : IMemoryCachingBuilder { return WithMemoryCaching(builder, x => { }); } + /// + /// Registers an implementation and applies the specified configuration actions. + /// + /// The concrete memory caching builder type to activate. + /// The RCommon builder instance. + /// A delegate to configure the caching builder. + /// The same for chaining. + /// + /// The builder type is created via + /// and must have a constructor that accepts an . + /// public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder, Action actions) where T : IMemoryCachingBuilder { Guard.IsNotNull(actions, nameof(actions)); - var cachingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Create the builder via reflection, passing the IRCommonBuilder to its constructor + var cachingConfig = (T)(Activator.CreateInstance(typeof(T), new object[] { builder }) + ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")); actions(cachingConfig); return builder; } + /// + /// Registers an implementation with default configuration. + /// + /// The concrete distributed caching builder type to activate. + /// The RCommon builder instance. + /// The same for chaining. public static IRCommonBuilder WithDistributedCaching(this IRCommonBuilder builder) where T : IDistributedCachingBuilder { return WithDistributedCaching(builder, x => { }); } + /// + /// Registers an implementation and applies the specified configuration actions. + /// + /// The concrete distributed caching builder type to activate. + /// The RCommon builder instance. + /// A delegate to configure the caching builder. + /// The same for chaining. + /// + /// The builder type is created via + /// and must have a constructor that accepts an . + /// public static IRCommonBuilder WithDistributedCaching(this IRCommonBuilder builder, Action actions) where T : IDistributedCachingBuilder { Guard.IsNotNull(actions, nameof(actions)); - var cachingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Create the builder via reflection, passing the IRCommonBuilder to its constructor + var cachingConfig = (T)(Activator.CreateInstance(typeof(T), new object[] { builder }) + ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")); actions(cachingConfig); return builder; } diff --git a/Src/RCommon.Caching/ExpressionCachingStrategy.cs b/Src/RCommon.Caching/ExpressionCachingStrategy.cs index fb708bfe..5836f6f4 100644 --- a/Src/RCommon.Caching/ExpressionCachingStrategy.cs +++ b/Src/RCommon.Caching/ExpressionCachingStrategy.cs @@ -6,8 +6,18 @@ namespace RCommon.Caching { + /// + /// Defines the strategy used when caching dynamically compiled expressions and lambdas. + /// + /// + /// Used as the key type for to resolve + /// the appropriate implementation at runtime. + /// public enum ExpressionCachingStrategy { + /// + /// The default expression caching strategy. + /// Default } } diff --git a/Src/RCommon.Caching/ICacheService.cs b/Src/RCommon.Caching/ICacheService.cs index c1165c1e..c376abbc 100644 --- a/Src/RCommon.Caching/ICacheService.cs +++ b/Src/RCommon.Caching/ICacheService.cs @@ -6,9 +6,34 @@ namespace RCommon.Caching { + /// + /// Provides a uniform interface for cache read-through (get-or-create) operations, + /// regardless of the underlying caching provider. + /// + /// + /// Implementations include InMemoryCacheService, DistributedMemoryCacheService, + /// and RedisCacheService. + /// public interface ICacheService { + /// + /// Returns the cached value for the specified key, or creates, caches, and returns a new value + /// using the provided factory delegate when no cached entry exists. + /// + /// The type of the cached data. + /// The cache key. + /// A factory delegate invoked to produce the value when the key is not found in cache. + /// The cached or newly created value of type . TData GetOrCreate(object key, Func data); + + /// + /// Asynchronously returns the cached value for the specified key, or creates, caches, and returns + /// a new value using the provided factory delegate when no cached entry exists. + /// + /// The type of the cached data. + /// The cache key. + /// A factory delegate invoked to produce the value when the key is not found in cache. + /// A task representing the cached or newly created value of type . Task GetOrCreateAsync(object key, Func data); } } diff --git a/Src/RCommon.Caching/IDistributedCachingBuilder.cs b/Src/RCommon.Caching/IDistributedCachingBuilder.cs index ee5df102..fd6f64a6 100644 --- a/Src/RCommon.Caching/IDistributedCachingBuilder.cs +++ b/Src/RCommon.Caching/IDistributedCachingBuilder.cs @@ -7,8 +7,15 @@ namespace RCommon.Caching { + /// + /// Defines the contract for configuring distributed caching services within the RCommon builder pipeline. + /// + /// public interface IDistributedCachingBuilder { + /// + /// Gets the used to register distributed caching dependencies. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Caching/IMemoryCachingBuilder.cs b/Src/RCommon.Caching/IMemoryCachingBuilder.cs index cacbefac..a5c86ca5 100644 --- a/Src/RCommon.Caching/IMemoryCachingBuilder.cs +++ b/Src/RCommon.Caching/IMemoryCachingBuilder.cs @@ -7,8 +7,15 @@ namespace RCommon.Caching { + /// + /// Defines the contract for configuring in-memory caching services within the RCommon builder pipeline. + /// + /// public interface IMemoryCachingBuilder { + /// + /// Gets the used to register memory caching dependencies. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Caching/RCommon.Caching.csproj b/Src/RCommon.Caching/RCommon.Caching.csproj index 2bbf5c68..84c84e91 100644 --- a/Src/RCommon.Caching/RCommon.Caching.csproj +++ b/Src/RCommon.Caching/RCommon.Caching.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Caching https://rcommon.com diff --git a/Src/RCommon.Caching/README.md b/Src/RCommon.Caching/README.md index b0465bd7..fa033b34 100644 --- a/Src/RCommon.Caching/README.md +++ b/Src/RCommon.Caching/README.md @@ -1,3 +1,66 @@ - # RCommon.Caching +# RCommon.Caching -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides the core caching abstractions for RCommon, including the `ICacheService` interface, strongly-typed cache keys, and builder contracts for plugging in memory or distributed caching providers. + +## Features + +- `ICacheService` interface with generic `GetOrCreate` and `GetOrCreateAsync` methods (read-through / get-or-create pattern) +- `CacheKey` value type with validation, max-length enforcement (256 chars), and factory methods for composite and type-scoped keys +- `IMemoryCachingBuilder` and `IDistributedCachingBuilder` contracts for provider-agnostic DI configuration +- `WithMemoryCaching` and `WithDistributedCaching` extension methods on `IRCommonBuilder` for fluent setup +- `ExpressionCachingStrategy` enum for strategy-based resolution of cache services used to cache dynamically compiled expressions + +## Installation + +```shell +dotnet add package RCommon.Caching +``` + +## Usage + +This package is typically consumed indirectly through a concrete provider such as `RCommon.MemoryCache` or `RCommon.RedisCache`. You can also program against the abstraction directly: + +```csharp +// Inject ICacheService and use the get-or-create pattern +public class ProductService +{ + private readonly ICacheService _cache; + + public ProductService(ICacheService cache) + { + _cache = cache; + } + + public async Task GetProductAsync(int id) + { + return await _cache.GetOrCreateAsync( + CacheKey.With("product", id.ToString()), + () => _productRepository.FindAsync(id)); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `ICacheService` | Core abstraction providing `GetOrCreate` and `GetOrCreateAsync` for read-through caching | +| `CacheKey` | Strongly-typed cache key with validation, max-length enforcement, and static factory methods | +| `IMemoryCachingBuilder` | Builder contract for configuring in-memory caching providers | +| `IDistributedCachingBuilder` | Builder contract for configuring distributed caching providers | +| `ExpressionCachingStrategy` | Strategy enum used to resolve the appropriate `ICacheService` for expression caching | +| `CachingBuilderExtensions` | `WithMemoryCaching` and `WithDistributedCaching` extensions on `IRCommonBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.MemoryCache](https://www.nuget.org/packages/RCommon.MemoryCache) - In-process and distributed memory cache implementations of `ICacheService` +- [RCommon.RedisCache](https://www.nuget.org/packages/RCommon.RedisCache) - Redis-backed distributed cache implementation of `ICacheService` +- [RCommon.Persistence.Caching](https://www.nuget.org/packages/RCommon.Persistence.Caching) - Caching decorator repositories that layer caching over any persistence provider + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Core/BaseApplicationException.cs b/Src/RCommon.Core/BaseApplicationException.cs index e1952246..9706b40c 100644 --- a/Src/RCommon.Core/BaseApplicationException.cs +++ b/Src/RCommon.Core/BaseApplicationException.cs @@ -13,15 +13,24 @@ namespace RCommon { + /// + /// Serves as the base class for application-specific exceptions, automatically capturing + /// environment information such as machine name, thread identity, and AppDomain name. + /// + /// + /// All constructors invoke to populate + /// diagnostic properties. Security exceptions during environment probing are silently + /// caught and the corresponding property is set to "Permission Denied". + /// [Serializable] public class BaseApplicationException : ApplicationException { #region Fields - private string machineName; - private string appDomainName; - private string threadIdentity; - private string windowsIdentity; + private string machineName = string.Empty; + private string appDomainName = string.Empty; + private string threadIdentity = string.Empty; + private string windowsIdentity = string.Empty; private DateTime createdDateTime = DateTime.Now; // Collection provided to store any extra information associated with the exception. @@ -140,7 +149,7 @@ private void InitializeEnvironmentInformation() { if (Thread.CurrentPrincipal != null) { - threadIdentity = Thread.CurrentPrincipal.Identity.Name; + threadIdentity = Thread.CurrentPrincipal.Identity?.Name ?? string.Empty; } } catch (SecurityException) @@ -156,7 +165,7 @@ private void InitializeEnvironmentInformation() { if (Thread.CurrentPrincipal != null) { - windowsIdentity = Thread.CurrentPrincipal.Identity.Name; + windowsIdentity = Thread.CurrentPrincipal.Identity?.Name ?? string.Empty; } } diff --git a/Src/RCommon.Core/CachingOptions.cs b/Src/RCommon.Core/CachingOptions.cs index 9c20829e..4680be9c 100644 --- a/Src/RCommon.Core/CachingOptions.cs +++ b/Src/RCommon.Core/CachingOptions.cs @@ -6,15 +6,30 @@ namespace RCommon { + /// + /// Configuration options for controlling caching behavior within RCommon. + /// Both options default to false (caching disabled). + /// public class CachingOptions { + /// + /// Initializes a new instance of with caching disabled by default. + /// public CachingOptions() { this.CachingEnabled = false; this.CacheDynamicallyCompiledExpressions = false; } + /// + /// Gets or sets a value indicating whether caching is globally enabled. + /// public bool CachingEnabled { get; set; } + + /// + /// Gets or sets a value indicating whether dynamically compiled expressions (e.g., specification predicates) + /// should be cached to improve performance. + /// public bool CacheDynamicallyCompiledExpressions { get; set; } } } diff --git a/Src/RCommon.Core/Collections/IPaginatedList.cs b/Src/RCommon.Core/Collections/IPaginatedList.cs index fc8f0b58..e9aa6818 100644 --- a/Src/RCommon.Core/Collections/IPaginatedList.cs +++ b/Src/RCommon.Core/Collections/IPaginatedList.cs @@ -5,13 +5,29 @@ namespace RCommon.Collections { + /// + /// Represents a paginated list that extends with pagination metadata + /// such as page index, page size, total count, and navigation flags. + /// + /// The type of elements in the list. public interface IPaginatedList : IList { + /// Gets a value indicating whether there is a next page available. bool HasNextPage { get; } + + /// Gets a value indicating whether there is a previous page available. bool HasPreviousPage { get; } + + /// Gets the 1-based index of the current page. int PageIndex { get; } + + /// Gets the maximum number of items per page. int PageSize { get; } + + /// Gets the total number of items across all pages. int TotalCount { get; } + + /// Gets the total number of pages. int TotalPages { get; } } } diff --git a/Src/RCommon.Core/Collections/ListDictionary.cs b/Src/RCommon.Core/Collections/ListDictionary.cs index 37e79c88..ffd92cf9 100644 --- a/Src/RCommon.Core/Collections/ListDictionary.cs +++ b/Src/RCommon.Core/Collections/ListDictionary.cs @@ -4,15 +4,30 @@ namespace RCommon.Collections { - public class ListDictionary : IEnumerable>> + /// + /// A dictionary that maps each key to a list of values, allowing multiple values per key. + /// Automatically creates an empty list when accessing a key that does not yet exist. + /// + /// The type of the dictionary keys. + /// The type of the values stored in the lists. + public class ListDictionary : IEnumerable>> where TKey : notnull { readonly Dictionary> innerValues = new Dictionary>(); + /// + /// Gets the number of keys in the dictionary. + /// public int Count { get { return innerValues.Count; } } + /// + /// Gets or sets the list of values associated with the specified key. + /// If the key does not exist on get, an empty list is created and associated with the key. + /// + /// The key to look up. + /// The list of values for the specified key. public List this[TKey key] { get @@ -25,11 +40,17 @@ public List this[TKey key] set { innerValues[key] = value; } } + /// + /// Gets a collection of all keys in the dictionary. + /// public ICollection Keys { get { return innerValues.Keys; } } + /// + /// Gets a flattened list of all values across all keys. + /// public List Values { get @@ -43,18 +64,28 @@ public List Values } } + /// + /// Adds a key to the dictionary with an empty value list. + /// + /// The key to add. Must not be null. public void Add(TKey key) { - Guard.IsNotNull(key, "key"); + Guard.IsNotNull(key!, "key"); CreateNewList(key); } + /// + /// Adds a value to the list associated with the specified key. + /// If the key does not exist, a new list is created first. + /// + /// The key to add the value under. Must not be null. + /// The value to add. Must not be null. public void Add(TKey key, TValue value) { - Guard.IsNotNull(key, "key"); - Guard.IsNotNull(value, "value"); + Guard.IsNotNull(key!, "key"); + Guard.IsNotNull(value!, "value"); if (innerValues.ContainsKey(key)) innerValues[key].Add(value); @@ -65,18 +96,31 @@ public void Add(TKey key, } } + /// + /// Removes all keys and their associated value lists from the dictionary. + /// public void Clear() { innerValues.Clear(); } + /// + /// Determines whether the dictionary contains the specified key. + /// + /// The key to locate. Must not be null. + /// true if the dictionary contains the key; otherwise, false. public bool ContainsKey(TKey key) { - Guard.IsNotNull(key, "key"); + Guard.IsNotNull(key!, "key"); return innerValues.ContainsKey(key); } + /// + /// Determines whether any value list in the dictionary contains the specified value. + /// + /// The value to search for. + /// true if the value is found in any list; otherwise, false. public bool ContainsValue(TValue value) { foreach (KeyValuePair> pair in innerValues) @@ -86,6 +130,11 @@ public bool ContainsValue(TValue value) return false; } + /// + /// Creates a new empty value list and associates it with the specified key. + /// + /// The key to associate the new list with. + /// The newly created empty list. List CreateNewList(TKey key) { List values = new List(); @@ -93,6 +142,11 @@ List CreateNewList(TKey key) return values; } + /// + /// Finds all values whose keys match the specified filter predicate. + /// + /// A predicate to filter keys. + /// An enumerable of values from all matching keys. public IEnumerable FindByKey(Predicate keyFilter) { foreach (KeyValuePair> pair in this) @@ -101,6 +155,12 @@ public IEnumerable FindByKey(Predicate keyFilter) yield return value; } + /// + /// Finds all values where both the key and value match the specified filter predicates. + /// + /// A predicate to filter keys. + /// A predicate to filter values within matching keys. + /// An enumerable of values matching both filters. public IEnumerable FindByKeyAndValue(Predicate keyFilter, Predicate valueFilter) { @@ -111,6 +171,11 @@ public IEnumerable FindByKeyAndValue(Predicate keyFilter, yield return value; } + /// + /// Finds all values across all keys that match the specified value filter predicate. + /// + /// A predicate to filter values. + /// An enumerable of matching values. public IEnumerable FindByValue(Predicate valueFilter) { foreach (KeyValuePair> pair in this) @@ -119,36 +184,52 @@ public IEnumerable FindByValue(Predicate valueFilter) yield return value; } + /// public IEnumerator>> GetEnumerator() { return innerValues.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return innerValues.GetEnumerator(); } + /// + /// Removes the key and its entire associated value list from the dictionary. + /// + /// The key to remove. Must not be null. + /// true if the key was found and removed; otherwise, false. public bool Remove(TKey key) { - Guard.IsNotNull(key, "key"); + Guard.IsNotNull(key!, "key"); return innerValues.Remove(key); } + /// + /// Removes all occurrences of a specific value from the list associated with the specified key. + /// + /// The key whose value list to modify. Must not be null. + /// The value to remove. Must not be null. public void Remove(TKey key, TValue value) { - Guard.IsNotNull(key, "key"); - Guard.IsNotNull(value, "value"); + Guard.IsNotNull(key!, "key"); + Guard.IsNotNull(value!, "value"); if (innerValues.ContainsKey(key)) innerValues[key].RemoveAll(delegate (TValue item) { - return value.Equals(item); + return value!.Equals(item); }); } + /// + /// Removes a specific value from all keys' value lists in the dictionary. + /// + /// The value to remove from all lists. public void Remove(TValue value) { foreach (KeyValuePair> pair in innerValues) diff --git a/Src/RCommon.Core/Collections/PaginatedList.cs b/Src/RCommon.Core/Collections/PaginatedList.cs index 730f7939..c87a738b 100644 --- a/Src/RCommon.Core/Collections/PaginatedList.cs +++ b/Src/RCommon.Core/Collections/PaginatedList.cs @@ -5,28 +5,57 @@ namespace RCommon.Collections { + /// + /// A concrete implementation of that extends + /// to provide pagination over a data source. Supports construction from , + /// , and sources. + /// + /// The type of elements in the paginated list. public class PaginatedList : List, IPaginatedList { + /// + /// Initializes a new empty instance of . + /// public PaginatedList() { } + /// Gets the 1-based index of the current page. public int PageIndex { get; private set; } + + /// Gets the maximum number of items per page. public int PageSize { get; private set; } + + /// Gets the total number of items across all pages. public int TotalCount { get; private set; } + + /// Gets the total number of pages. public int TotalPages { get; private set; } + /// + /// Initializes a new instance of from an source. + /// + /// The queryable data source to paginate. + /// The 1-based page index, or null to default to the first page. + /// The number of items per page. public PaginatedList(IQueryable source, int? pageIndex, int pageSize) { PageIndex = pageIndex ?? 1; PageSize = pageSize; TotalCount = source.Count(); + // Calculate total pages using integer division, ensuring at least 1 page TotalPages = ((TotalCount - 1) / PageSize) + 1; this.AddRange(source.Skip((PageIndex - 1) * PageSize).Take(PageSize)); } + /// + /// Initializes a new instance of from an source. + /// + /// The list data source to paginate. + /// The 1-based page index, or null to default to the first page. + /// The number of items per page. public PaginatedList(IList source, int? pageIndex, int pageSize) { PageIndex = pageIndex ?? 1; @@ -37,6 +66,12 @@ public PaginatedList(IList source, int? pageIndex, int pageSize) this.AddRange(source.Skip((PageIndex - 1) * PageSize).Take(PageSize)); } + /// + /// Initializes a new instance of from an source. + /// + /// The collection data source to paginate. + /// The 1-based page index, or null to default to the first page. + /// The number of items per page. public PaginatedList(ICollection source, int? pageIndex, int pageSize) { PageIndex = pageIndex ?? 1; @@ -48,6 +83,9 @@ public PaginatedList(ICollection source, int? pageIndex, int pageSize) this.AddRange(source.Skip((PageIndex - 1) * PageSize).Take(PageSize)); } + /// + /// Gets a value indicating whether there is a previous page (i.e., is greater than 1). + /// public bool HasPreviousPage { get @@ -56,6 +94,9 @@ public bool HasPreviousPage } } + /// + /// Gets a value indicating whether there is a next page (i.e., is less than ). + /// public bool HasNextPage { get diff --git a/Src/RCommon.Core/CommonFactory.cs b/Src/RCommon.Core/CommonFactory.cs index a568b07c..173cf3c5 100644 --- a/Src/RCommon.Core/CommonFactory.cs +++ b/Src/RCommon.Core/CommonFactory.cs @@ -6,20 +6,31 @@ namespace RCommon { + /// + /// Default implementation of that uses a + /// delegate to create instances of . + /// + /// The type of object this factory creates. public class CommonFactory : ICommonFactory { private readonly Func _initFunc; + /// + /// Initializes a new instance of with the specified initialization function. + /// + /// A delegate that creates new instances of . public CommonFactory(Func initFunc) { _initFunc = initFunc; } + /// public TResult Create() { return _initFunc(); } + /// public TResult Create(Action customize) { var concreteObject = _initFunc(); @@ -28,20 +39,32 @@ public TResult Create(Action customize) } } + /// + /// Default implementation of that uses a + /// delegate to create instances of from a single argument. + /// + /// The type of the input argument. + /// The type of object this factory creates. public class CommonFactory : ICommonFactory { private readonly Func _initFunc; + /// + /// Initializes a new instance of with the specified initialization function. + /// + /// A delegate that creates new instances of from an argument of type . public CommonFactory(Func initFunc) { _initFunc = initFunc; } + /// public TResult Create(T arg) { return _initFunc(arg); } + /// public TResult Create(T arg, Action customize) { var concreteObject = _initFunc(arg); @@ -52,20 +75,33 @@ public TResult Create(T arg, Action customize) } + /// + /// Default implementation of that uses a + /// delegate to create instances of from two arguments. + /// + /// The type of the first input argument. + /// The type of the second input argument. + /// The type of object this factory creates. public class CommonFactory : ICommonFactory { private readonly Func _initFunc; + /// + /// Initializes a new instance of with the specified initialization function. + /// + /// A delegate that creates new instances of from arguments of type and . public CommonFactory(Func initFunc) { _initFunc = initFunc; } + /// public TResult Create(T arg, T2 arg2) { return _initFunc(arg, arg2); } + /// public TResult Create(T arg, T2 arg2, Action customize) { var concreteObject = _initFunc(arg, arg2); diff --git a/Src/RCommon.Core/Constants.cs b/Src/RCommon.Core/Constants.cs index 1ef3e74d..63e99601 100644 --- a/Src/RCommon.Core/Constants.cs +++ b/Src/RCommon.Core/Constants.cs @@ -6,8 +6,15 @@ namespace RCommon { + /// + /// Provides commonly used application-wide constant values for domain object identifiers, + /// versioning, delimiters, and paging defaults. + /// public static class Constants { + /// + /// Gets the current thread's culture information. + /// public static CultureInfo CurrentCulture { get @@ -16,18 +23,52 @@ public static CultureInfo CurrentCulture } } + /// + /// Default identifier value for a domain object that has not yet been persisted. + /// public const int IdOfUnsavedDomainObject = 0; + + /// + /// Sentinel identifier used for stub/mock domain objects in testing scenarios. + /// public const int IdOfStubDomainObject = -999999999; // used as the Id of Stub objects, should be renamed to IdOfStubObject - public const int IndexOfStubDomainObject = -1000; // a special Id to indicate a "Not-A-Domain-Object" (e.g., + + /// + /// Special index value indicating a non-domain object (e.g., artificial object types + /// such as ObjectDescriptor, ObjectAndTypeElement, or None-Entry items for combo boxes). + /// + public const int IndexOfStubDomainObject = -1000; // a special Id to indicate a "Not-A-Domain-Object" (e.g., // artificial object types such as: ObjectDescriptor, ObjectAndTypeElement, None-Entry Item (for combobox) + + /// + /// Represents an invalid or uninitialized version number for optimistic concurrency control. + /// public const int InvalidVersion = -1; + /// + /// The index of the first real (non-stub) object in a data list, typically following a placeholder entry. + /// public static readonly int IndexOfFirstNonStubObjectInDataList = 1; + /// + /// The default delimiter character used for string splitting and joining operations. + /// public static readonly char DefaultDelimiter = ','; + + /// + /// The default number of items per page for paged queries. + /// public static readonly int DefaultPageSize = 10; + + /// + /// The default property name used to resolve singleton instances. + /// public static readonly string DefaultSingletonPropertyName = "Singleton"; + + /// + /// The default property name used for optimistic concurrency version tracking. + /// public static readonly string DefaultVersionPropertyName = "ObjectVersion"; } } diff --git a/Src/RCommon.Core/DisposableAction.cs b/Src/RCommon.Core/DisposableAction.cs index 438a1ce6..449b0a50 100644 --- a/Src/RCommon.Core/DisposableAction.cs +++ b/Src/RCommon.Core/DisposableAction.cs @@ -25,6 +25,9 @@ public DisposeAction(Action action) _action = action; } + /// + /// Executes the action that was provided at construction time. + /// public void Dispose() { _action(); diff --git a/Src/RCommon.Core/DisposableResource.cs b/Src/RCommon.Core/DisposableResource.cs index 6d09ba1d..c67bd65b 100644 --- a/Src/RCommon.Core/DisposableResource.cs +++ b/Src/RCommon.Core/DisposableResource.cs @@ -7,13 +7,29 @@ namespace RCommon { + /// + /// Abstract base class that implements the standard Dispose pattern for both synchronous + /// () and asynchronous () disposal. + /// + /// + /// Derived classes should override and/or + /// to release managed and unmanaged resources. The finalizer calls + /// with false to release unmanaged resources only. + /// public abstract class DisposableResource : IDisposable, IAsyncDisposable { + /// + /// Finalizer that invokes with false to release unmanaged resources. + /// ~DisposableResource() { Dispose(false); } + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting resources. + /// Suppresses finalization after disposal. + /// [DebuggerStepThrough] public void Dispose() { @@ -21,16 +37,30 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Asynchronously performs application-defined tasks associated with freeing, releasing, or resetting resources. + /// Suppresses finalization after disposal. + /// + /// A representing the asynchronous dispose operation. public async ValueTask DisposeAsync() { await this.DisposeAsync(true); GC.SuppressFinalize(this); } + /// + /// Releases resources. Override in derived classes to release managed resources when is true. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { } + /// + /// Asynchronously releases resources. Override in derived classes to release managed resources when is true. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// A representing the asynchronous dispose operation. protected async virtual Task DisposeAsync(bool disposing) { diff --git a/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs b/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs index add6f1e7..382899de 100644 --- a/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs +++ b/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs @@ -13,23 +13,46 @@ namespace RCommon { + /// + /// Extension methods for configuring event handling on + /// and registering instances on . + /// public static class EventHandlingBuilderExtensions { + /// + /// Configures event handling using the specified builder with default settings. + /// + /// The implementation type to use. + /// The RCommon builder instance. + /// The for method chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder) where T : IEventHandlingBuilder { return WithEventHandling(builder, x => { }); } + /// + /// Configures event handling using the specified builder and applies custom configuration. + /// + /// The implementation type to use. + /// The RCommon builder instance. + /// An action to configure the event handling builder. + /// The for method chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions) where T : IEventHandlingBuilder { // Event Handling Configurations - var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(eventHandlingConfig); return builder; } + /// + /// Registers an of type as a singleton service + /// and associates it with the current builder type in the . + /// + /// The implementation type. + /// The event handling builder. public static void AddProducer(this IEventHandlingBuilder builder) where T : class, IEventProducer { @@ -40,6 +63,13 @@ public static void AddProducer(this IEventHandlingBuilder builder) subscriptionManager?.AddProducerForBuilder(builder.GetType(), typeof(T)); } + /// + /// Registers an of type as a singleton service + /// using a factory delegate, and associates it with the current builder type. + /// + /// The implementation type. + /// The event handling builder. + /// A factory function to create the producer instance. public static void AddProducer(this IEventHandlingBuilder builder, Func getProducer) where T : class, IEventProducer { @@ -50,12 +80,21 @@ public static void AddProducer(this IEventHandlingBuilder builder, Func + /// Registers an existing instance as a singleton and associates it + /// with the current builder type. Also registers the producer as an + /// if it implements that interface. + /// + /// The implementation type. + /// The event handling builder. + /// The producer instance to register. public static void AddProducer(this IEventHandlingBuilder builder, T producer) where T : class, IEventProducer { builder.Services.TryAddSingleton(producer); builder.Services.TryAddSingleton(sp => sp.GetRequiredService()); + // Also register as IHostedService if the producer implements it if (producer is IHostedService service) { builder.Services.TryAddSingleton(service); @@ -70,6 +109,8 @@ public static void AddProducer(this IEventHandlingBuilder builder, T producer /// Retrieves the singleton instance from the service collection /// during configuration time (before the service provider is built). /// + /// The service collection to search. + /// The instance, or null if not registered. public static EventSubscriptionManager? GetSubscriptionManager(this IServiceCollection services) { var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(EventSubscriptionManager)); diff --git a/Src/RCommon.Core/EventHandling/IEventBus.cs b/Src/RCommon.Core/EventHandling/IEventBus.cs index c96eb56b..17034927 100644 --- a/Src/RCommon.Core/EventHandling/IEventBus.cs +++ b/Src/RCommon.Core/EventHandling/IEventBus.cs @@ -2,12 +2,36 @@ namespace RCommon.EventHandling { + /// + /// Defines an in-process event bus for publishing events and subscribing event handlers. + /// + /// public interface IEventBus { + /// + /// Publishes an event to all registered subscribers of . + /// + /// The type of event to publish. + /// The event instance to publish. + /// A representing the asynchronous operation. Task PublishAsync(TEvent @event); + + /// + /// Subscribes a specific event handler to a specific event type. + /// + /// The type of event to subscribe to. + /// The handler type that implements . + /// The instance for method chaining. IEventBus Subscribe() where TEvent : class where TEventHandler : class, Subscribers.ISubscriber; + + /// + /// Automatically subscribes to all event types it handles + /// by discovering all interface implementations. + /// + /// The handler type whose implementations are auto-discovered. + /// The instance for method chaining. IEventBus SubscribeAllHandledEvents() where TEventHandler : class; } } \ No newline at end of file diff --git a/Src/RCommon.Core/EventHandling/IEventHandlingBuilder.cs b/Src/RCommon.Core/EventHandling/IEventHandlingBuilder.cs index d74bd689..3fa8a4e8 100644 --- a/Src/RCommon.Core/EventHandling/IEventHandlingBuilder.cs +++ b/Src/RCommon.Core/EventHandling/IEventHandlingBuilder.cs @@ -7,8 +7,15 @@ namespace RCommon.EventHandling { + /// + /// Defines the base builder interface for configuring event handling infrastructure. + /// Provides access to the for service registration. + /// public interface IEventHandlingBuilder { + /// + /// Gets the used to register event handling services. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Core/EventHandling/IInMemoryEventBusBuilder.cs b/Src/RCommon.Core/EventHandling/IInMemoryEventBusBuilder.cs index f82742ac..97c78bf6 100644 --- a/Src/RCommon.Core/EventHandling/IInMemoryEventBusBuilder.cs +++ b/Src/RCommon.Core/EventHandling/IInMemoryEventBusBuilder.cs @@ -2,8 +2,14 @@ namespace RCommon.EventHandling { + /// + /// Marker interface for the in-memory event bus builder. Extends + /// to provide a distinct builder type for configuring in-memory event handling with + /// . + /// + /// public interface IInMemoryEventBusBuilder : IEventHandlingBuilder { - + } } diff --git a/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs b/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs index 443870ea..aa224abe 100644 --- a/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs +++ b/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs @@ -34,17 +34,31 @@ namespace RCommon.EventHandling { + /// + /// In-memory implementation of that resolves and invokes + /// handlers from the dependency injection container. + /// + /// + /// Subscriptions are registered directly into the at configuration time. + /// Publishing creates a new scope and resolves all handlers via reflection to support polymorphic event dispatch. + /// public class InMemoryEventBus : IEventBus { private readonly IServiceCollection _services; private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The root service provider used to create scopes for event publishing. + /// The service collection for registering subscriber services at configuration time. public InMemoryEventBus(IServiceProvider serviceProvider, IServiceCollection services) { _serviceProvider = serviceProvider; _services = services; } + /// public IEventBus Subscribe() where TEvent : class where TEventHandler : class, ISubscriber @@ -53,10 +67,16 @@ public IEventBus Subscribe() return this; } + /// + /// + /// Uses reflection to discover all interfaces on + /// and registers each as a scoped service. + /// public IEventBus SubscribeAllHandledEvents() where TEventHandler : class { Type implementationType = typeof(TEventHandler); + // Discover all ISubscriber<> interfaces implemented by the handler type IEnumerable serviceTypes = implementationType .GetInterfaces() .Where(i => i.IsGenericType) @@ -70,21 +90,33 @@ public IEventBus SubscribeAllHandledEvents() return this; } + /// + /// + /// Creates a new DI scope and uses reflection to resolve handlers for the runtime event type, + /// invoking on each handler sequentially. + /// public async Task PublishAsync(TEvent @event) { using (IServiceScope scope = _serviceProvider.CreateScope()) { - Type eventType = @event.GetType(); + // Resolve handlers based on the runtime event type (not the compile-time generic parameter) + Type eventType = @event!.GetType(); Type openHandlerType = typeof(ISubscriber<>); Type handlerType = openHandlerType.MakeGenericType(eventType); - IEnumerable handlers = scope.ServiceProvider.GetServices(handlerType); - foreach (object handler in handlers) + IEnumerable handlers = scope.ServiceProvider.GetServices(handlerType); + foreach (object? handler in handlers) { - object result = handlerType + if (handler == null) continue; + + // Invoke HandleAsync via reflection to support polymorphic dispatch + object? result = handlerType .GetTypeInfo() .GetDeclaredMethod(nameof(ISubscriber.HandleAsync)) - .Invoke(handler, new object[] { @event, CancellationToken.None}); - await (Task)result; + ?.Invoke(handler, new object[] { @event, CancellationToken.None}); + if (result is Task task) + { + await task; + } } } } diff --git a/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilder.cs b/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilder.cs index 05697743..f29a4586 100644 --- a/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilder.cs +++ b/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilder.cs @@ -7,15 +7,24 @@ namespace RCommon.EventHandling { + /// + /// Default implementation of that configures + /// in-memory event handling by exposing the from the parent + /// . + /// public class InMemoryEventBusBuilder : IInMemoryEventBusBuilder { - + /// + /// Initializes a new instance of using the parent builder's service collection. + /// + /// The parent whose will be used. public InMemoryEventBusBuilder(IRCommonBuilder builder) { Services = builder.Services; } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilderExtensions.cs b/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilderExtensions.cs index 5e59511b..11dce2d8 100644 --- a/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilderExtensions.cs +++ b/Src/RCommon.Core/EventHandling/InMemoryEventBusBuilderExtensions.cs @@ -12,8 +12,19 @@ namespace RCommon.EventHandling { + /// + /// Extension methods for to register event subscribers + /// and associate them with the correct event producers via the . + /// public static class InMemoryEventBusBuilderExtensions { + /// + /// Registers a scoped subscriber for the specified event type and records the event-to-producer + /// subscription so the routes this event only to the correct producers. + /// + /// The event type to subscribe to. Must implement . + /// The subscriber type that handles . + /// The in-memory event bus builder. public static void AddSubscriber(this IInMemoryEventBusBuilder builder) where TEvent : class, ISerializableEvent where TEventHandler : class, ISubscriber @@ -25,6 +36,14 @@ public static void AddSubscriber(this IInMemoryEventBusBu subscriptionManager?.AddSubscription(builder.GetType(), typeof(TEvent)); } + /// + /// Registers a scoped subscriber for the specified event type using a factory delegate, + /// and records the event-to-producer subscription for correct event routing. + /// + /// The event type to subscribe to. Must implement . + /// The subscriber type that handles . + /// The in-memory event bus builder. + /// A factory function to create the subscriber instance. public static void AddSubscriber(this IInMemoryEventBusBuilder builder, Func getSubscriber) where TEvent : class, ISerializableEvent where TEventHandler : class, ISubscriber diff --git a/Src/RCommon.Core/EventHandling/Producers/EventProductionException.cs b/Src/RCommon.Core/EventHandling/Producers/EventProductionException.cs index b2a2821f..9763a82d 100644 --- a/Src/RCommon.Core/EventHandling/Producers/EventProductionException.cs +++ b/Src/RCommon.Core/EventHandling/Producers/EventProductionException.cs @@ -6,18 +6,37 @@ namespace RCommon.EventHandling.Producers { + /// + /// Exception thrown when an error occurs during event production through an + /// or the . + /// public class EventProductionException : GeneralException { + /// + /// Initializes a new instance of with the specified message. + /// + /// The error message. public EventProductionException(string message) : base(message) { - + } + /// + /// Initializes a new instance of with a parameterized message. + /// + /// The message format string. + /// Parameters to format into the message string. public EventProductionException(string message, object[] @params) : base(message, @params) { } + /// + /// Initializes a new instance of with a parameterized message and inner exception. + /// + /// The message format string. + /// The inner exception that caused this error. + /// Parameters to format into the message string. public EventProductionException(string message, Exception exception, object[] @params) : base(message, exception, @params) { diff --git a/Src/RCommon.Core/EventHandling/Producers/IEventProducer.cs b/Src/RCommon.Core/EventHandling/Producers/IEventProducer.cs index d06fb2cb..c9f06a88 100644 --- a/Src/RCommon.Core/EventHandling/Producers/IEventProducer.cs +++ b/Src/RCommon.Core/EventHandling/Producers/IEventProducer.cs @@ -8,9 +8,21 @@ namespace RCommon.EventHandling.Producers { + /// + /// Defines a producer responsible for dispatching serializable events to their destination + /// (e.g., in-memory bus, message broker, or external system). + /// + /// public interface IEventProducer { - Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default) + /// + /// Produces (dispatches) the specified event asynchronously. + /// + /// The type of event to produce. Must implement . + /// The event instance to produce. + /// A token to observe for cancellation requests. + /// A representing the asynchronous produce operation. + Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : ISerializableEvent; } } diff --git a/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs b/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs index c6e036b5..486eaf82 100644 --- a/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs +++ b/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs @@ -4,18 +4,23 @@ namespace RCommon.EventHandling.Producers { + /// + /// Defines a router responsible for storing transactional events and dispatching them to the appropriate + /// instances when ready. + /// + /// public interface IEventRouter { /// /// Adds a serializable event to the transactional event store so that it can be published when the consumer is ready. /// - /// + /// The event to add to the transactional store. void AddTransactionalEvent(ISerializableEvent serializableEvent); /// /// Adds a collection of serializable events to the transactional event store so that it can be published when the consumer is ready. /// - /// + /// The collection of events to add to the transactional store. void AddTransactionalEvents(IEnumerable serializableEvents); /// diff --git a/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs b/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs index a9d7c881..7cf810cb 100644 --- a/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs +++ b/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs @@ -22,6 +22,12 @@ public class InMemoryTransactionalEventRouter : IEventRouter private readonly EventSubscriptionManager _subscriptionManager; private ConcurrentQueue _storedTransactionalEvents; + /// + /// Initializes a new instance of . + /// + /// The service provider used to resolve instances. + /// The logger for diagnostic output. + /// The manager that tracks event-to-producer subscriptions for filtering. public InMemoryTransactionalEventRouter(IServiceProvider serviceProvider, ILogger logger, EventSubscriptionManager subscriptionManager) { @@ -31,6 +37,7 @@ public InMemoryTransactionalEventRouter(IServiceProvider serviceProvider, ILogge _storedTransactionalEvents = new ConcurrentQueue(); } + /// public async Task RouteEventsAsync(IEnumerable transactionalEvents) { try @@ -85,6 +92,11 @@ public async Task RouteEventsAsync(IEnumerable transactional } } + /// + /// Dispatches async events to their filtered producers concurrently using . + /// + /// The async events to produce. + /// All registered event producers (will be filtered per event). private async Task ProduceAsyncEvents(IEnumerable asyncEvents, IEnumerable eventProducers) { var eventTaskList = new List(); @@ -99,6 +111,11 @@ private async Task ProduceAsyncEvents(IEnumerable asyncEvent await Task.WhenAll(eventTaskList); } + /// + /// Dispatches sync events to their filtered producers sequentially, awaiting each before proceeding. + /// + /// The synchronous events to produce. + /// All registered event producers (will be filtered per event). private async Task ProduceSyncEvents(IEnumerable syncEvents, IEnumerable eventProducers) { foreach (var @event in syncEvents) @@ -129,12 +146,19 @@ public async Task RouteEventsAsync() } } + /// + /// Removes a batch of events from the concurrent queue with retry logic. + /// Each event is attempted up to 4 times before throwing an . + /// + /// The events to remove from the queue. + /// Thrown if an event cannot be dequeued after 4 attempts. private void RemoveEvents(IEnumerable events) { foreach (var @event in events) { var item = @event; + // Retry dequeue up to 4 times to handle ConcurrentQueue contention for (int i = 1; i <= 4; i++) // Try 4 times { if (!RemoveEvent(item)) @@ -151,22 +175,29 @@ private void RemoveEvents(IEnumerable events) throw new EventProductionException($"Could not Dequeue event {item}"); } } - + } } + /// + /// Attempts to dequeue a single event from the concurrent queue. + /// + /// The event to dequeue (used as output parameter for the dequeued item). + /// true if the dequeue was successful; otherwise false. private bool RemoveEvent(ISerializableEvent @event) { - bool success = _storedTransactionalEvents.TryDequeue(out @event); + bool success = _storedTransactionalEvents.TryDequeue(out ISerializableEvent? _); return success; } + /// public void AddTransactionalEvent(ISerializableEvent serializableEvent) { Guard.IsNotNull(serializableEvent, nameof(serializableEvent)); _storedTransactionalEvents.Enqueue(serializableEvent); } + /// public void AddTransactionalEvents(IEnumerable serializableEvents) { Guard.IsNotNull(serializableEvents, nameof(serializableEvents)); diff --git a/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs b/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs index 803a97ee..3ff47304 100644 --- a/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs +++ b/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs @@ -9,12 +9,23 @@ namespace RCommon.EventHandling.Producers { + /// + /// An implementation that produces events by publishing them + /// through the . Consults the + /// to determine whether this producer should handle a given event type. + /// public class PublishWithEventBusEventProducer : IEventProducer { private readonly IEventBus _eventBus; private readonly ILogger _logger; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The event bus used to publish events to subscribers. + /// The logger for diagnostic output. + /// The subscription manager that determines which events this producer handles. public PublishWithEventBusEventProducer(IEventBus eventBus, ILogger logger, EventSubscriptionManager subscriptionManager) { @@ -23,6 +34,11 @@ public PublishWithEventBusEventProducer(IEventBus eventBus, ILogger + /// + /// Before publishing, this producer checks the to determine + /// if it should handle the event. If not subscribed, the event is silently skipped. + /// public async Task ProduceEventAsync(T @event, CancellationToken cancellationToken = default) where T : ISerializableEvent { diff --git a/Src/RCommon.Core/EventHandling/Producers/UnsupportedEventProducerException.cs b/Src/RCommon.Core/EventHandling/Producers/UnsupportedEventProducerException.cs index 34ce9037..2fd6b634 100644 --- a/Src/RCommon.Core/EventHandling/Producers/UnsupportedEventProducerException.cs +++ b/Src/RCommon.Core/EventHandling/Producers/UnsupportedEventProducerException.cs @@ -6,13 +6,20 @@ namespace RCommon.EventHandling.Producers { + /// + /// Exception thrown when an attempt is made to use an that is not + /// supported or not properly configured for the current event handling pipeline. + /// public class UnsupportedEventProducerException : ApplicationException { - + /// + /// Initializes a new instance of with the specified message. + /// + /// The error message describing the unsupported producer. public UnsupportedEventProducerException(string message) : base(message) { } - + } } diff --git a/Src/RCommon.Core/EventHandling/Subscribers/IDynamicDistributedEventHandler.cs b/Src/RCommon.Core/EventHandling/Subscribers/IDynamicDistributedEventHandler.cs index baa947ab..bcd36127 100644 --- a/Src/RCommon.Core/EventHandling/Subscribers/IDynamicDistributedEventHandler.cs +++ b/Src/RCommon.Core/EventHandling/Subscribers/IDynamicDistributedEventHandler.cs @@ -2,8 +2,17 @@ namespace RCommon.EventHandling.Subscribers { + /// + /// Defines a handler for distributed events that accepts dynamically-typed event data, + /// allowing handling of events without compile-time knowledge of their concrete type. + /// public interface IDynamicDistributedEventHandler { + /// + /// Handles a distributed event with dynamically-typed event data. + /// + /// The event data as a dynamic object. + /// A representing the asynchronous handling operation. Task Handle(dynamic eventData); } } diff --git a/Src/RCommon.Core/EventHandling/Subscribers/ISubscriber.cs b/Src/RCommon.Core/EventHandling/Subscribers/ISubscriber.cs index 3fa16853..cf9897d6 100644 --- a/Src/RCommon.Core/EventHandling/Subscribers/ISubscriber.cs +++ b/Src/RCommon.Core/EventHandling/Subscribers/ISubscriber.cs @@ -10,8 +10,19 @@ namespace RCommon.EventHandling.Subscribers { + /// + /// Defines a strongly-typed event subscriber that handles events of type . + /// Implementations are resolved from the DI container when events are published via the . + /// + /// The type of event this subscriber handles. public interface ISubscriber { + /// + /// Handles the specified event asynchronously. + /// + /// The event instance to handle. + /// A token to observe for cancellation requests. + /// A representing the asynchronous handling operation. public Task HandleAsync(TEvent @event, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Core/Extensions/CollectionExtensions.cs b/Src/RCommon.Core/Extensions/CollectionExtensions.cs index 61fb1cca..bec0ac7d 100644 --- a/Src/RCommon.Core/Extensions/CollectionExtensions.cs +++ b/Src/RCommon.Core/Extensions/CollectionExtensions.cs @@ -17,6 +17,9 @@ namespace RCommon /// public static class CollectionExtensions { + /// + /// The default comma delimiter character used by delimited string methods. + /// public static readonly char CommaDelimiter = ','; /// @@ -25,7 +28,7 @@ public static class CollectionExtensions /// The list of elements to create delimited string from /// The string consisting of comma-separated elements (using the ToString() method) from the input list /// if is null - public static string GetCommaDelimitedString(this IEnumerable source) + public static string? GetCommaDelimitedString(this IEnumerable source) { return source.GetDelimitedString(CommaDelimiter); } @@ -38,7 +41,7 @@ public static string GetCommaDelimitedString(this IEnumerable source) /// The delegate to return data to be extracted from each element /// comma-delimited string /// if is null - public static string GetCommaDelimitedString(this IEnumerable source, + public static string? GetCommaDelimitedString(this IEnumerable source, Func funcToGetString) { return source.GetDelimitedString(funcToGetString, CommaDelimiter, false, true); @@ -52,9 +55,9 @@ public static string GetCommaDelimitedString(this IEnumerable source, /// The list of elements to create delimited string from /// The string consisting of delimiter-separated elements (using the ToString() method) from the input list /// if is null - public static string GetDelimitedString(this IEnumerable source, char delimiter) + public static string? GetDelimitedString(this IEnumerable source, char delimiter) { - return source.GetDelimitedString(t => t.ToString(), delimiter, false, true); + return source.GetDelimitedString(t => t?.ToString() ?? string.Empty, delimiter, false, true); } /// @@ -68,7 +71,7 @@ public static string GetDelimitedString(this IEnumerable source, char deli /// The string consisting of delimiter-separated elements (using the ToString() method) from the input list /// if is null /// if is null - public static string GetDelimitedString(this IEnumerable source, + public static string? GetDelimitedString(this IEnumerable source, Func funcToGetString, char delimiter, bool addLeadingDelimiter, bool removeTrailingDelimiter) { @@ -109,7 +112,7 @@ public static IList Copy(this IList list) /// /// A boolean indicating if the original order in the collectionWithPossibleDuplicates should be retained /// - public static IList ConvertToListWithNoDuplicates( + public static IList? ConvertToListWithNoDuplicates( this IEnumerable collectionWithPossibleDuplicates, bool retainOriginalOrder) { if (collectionWithPossibleDuplicates == null) return null; @@ -164,7 +167,7 @@ public static IList ConvertToList(this System.Collections.IEnumerable obje /// /// /// - public static T[] ConvertToArray(this IEnumerable source) + public static T[]? ConvertToArray(this IEnumerable source) { if (source == null) return null; @@ -256,6 +259,10 @@ public static void TryForEach(this IEnumerable collection, Action actio + /// + /// Helper method placeholder that always returns true. Used internally as a default predicate. + /// + /// Always returns true. private static bool ForEachHelper() { return true; @@ -279,6 +286,12 @@ public static void TryForEach(this IEnumerator enumerator, Action actio } } + /// + /// Determines whether the collection is null or contains no elements. + /// + /// The type of the elements in the collection. + /// The collection to check. + /// true if the collection is null or empty; otherwise, false. [DebuggerStepThrough] public static bool IsNullOrEmpty(this ICollection collection) { @@ -286,6 +299,12 @@ public static bool IsNullOrEmpty(this ICollection collection) } + /// + /// Delegate used to decide whether an item should be removed from a collection. + /// + /// The type of item to evaluate. + /// The item to evaluate. + /// true if the item should be removed; otherwise, false. public delegate bool Decide(T item); /// @@ -353,6 +372,12 @@ public static void RemoveItems(this ICollection collection, Action acti } + /// + /// Converts an to a by reflecting over the properties of the first element. + /// + /// The list of objects to convert. Must contain at least one element. + /// A populated with data from the list. + /// Thrown when is null. public static DataTable ToDataTable(this IList alist) { DataTable dt = new DataTable(); @@ -361,9 +386,9 @@ public static DataTable ToDataTable(this IList alist) { throw new FormatException("Parameter ArrayList empty"); } - dt.TableName = alist[0].GetType().Name; + dt.TableName = alist[0]!.GetType().Name; DataRow dr; - System.Reflection.PropertyInfo[] propInfo = alist[0].GetType().GetProperties(); + System.Reflection.PropertyInfo[] propInfo = alist[0]!.GetType().GetProperties(); for (int i = 0; i < propInfo.Length; i++) { dt.Columns.Add(propInfo[i].Name, propInfo[i].PropertyType); @@ -374,9 +399,9 @@ public static DataTable ToDataTable(this IList alist) dr = dt.NewRow(); for (int i = 0; i < propInfo.Length; i++) { - object tempObject = alist[row]; + object? tempObject = alist[row]; - object t = propInfo[i].GetValue(tempObject, null); + object? t = propInfo[i].GetValue(tempObject, null); /*object t =tempObject.GetType().InvokeMember(propInfo[i].Name, R.BindingFlags.GetProperty , null,tempObject , new object [] {});*/ if (t != null) @@ -387,6 +412,13 @@ public static DataTable ToDataTable(this IList alist) return dt; } + /// + /// Converts an to a , filtering columns to only those whose names appear in . + /// + /// The list of objects to convert. Must contain at least one element. + /// An of column names to include in the resulting . + /// A populated with the filtered column data from the list. + /// Thrown when is null. public static DataTable ToDataTable(this IList alist, ArrayList alColNames) { DataTable dt = new DataTable(); @@ -395,14 +427,14 @@ public static DataTable ToDataTable(this IList alist, ArrayList alColNames) { throw new FormatException("Parameter ArrayList empty"); } - dt.TableName = alist[0].GetType().Name; + dt.TableName = alist[0]!.GetType().Name; DataRow dr; - System.Reflection.PropertyInfo[] propInfo = alist[0].GetType().GetProperties(); + System.Reflection.PropertyInfo[] propInfo = alist[0]!.GetType().GetProperties(); for (int i = 0; i < propInfo.Length; i++) { for (int j = 0; j < alColNames.Count; j++) { - if (alColNames[j].ToString() == propInfo[i].Name) + if (alColNames[j]?.ToString() == propInfo[i].Name) { dt.Columns.Add(propInfo[i].Name, propInfo[i].PropertyType); break; @@ -415,9 +447,9 @@ public static DataTable ToDataTable(this IList alist, ArrayList alColNames) dr = dt.NewRow(); for (int i = 0; i < dt.Columns.Count; i++) { - object tempObject = alist[row]; + object? tempObject = alist[row]; - object t = propInfo[i].GetValue(tempObject, null); + object? t = propInfo[i].GetValue(tempObject, null); /*object t =tempObject.GetType().InvokeMember(propInfo[i].Name, R.BindingFlags.GetProperty , null,tempObject , new object [] {});*/ if (t != null) @@ -428,6 +460,15 @@ public static DataTable ToDataTable(this IList alist, ArrayList alColNames) return dt; } + /// + /// Conditionally filters an based on a boolean condition. + /// If the condition is false, the source is returned unfiltered. + /// + /// The type of elements in the source. + /// The source enumerable to filter. + /// If true, the predicate is applied; otherwise, the source is returned as-is. + /// The filter predicate to apply when is true. + /// A filtered or unfiltered enumerable depending on the condition. public static IEnumerable WhereIf(this IEnumerable source, bool condition, Func predicate) { if (condition) @@ -436,6 +477,15 @@ public static IEnumerable WhereIf(this IEnumerable so return source; } + /// + /// Conditionally filters an based on a boolean condition, using an index-aware predicate. + /// If the condition is false, the source is returned unfiltered. + /// + /// The type of elements in the source. + /// The source enumerable to filter. + /// If true, the predicate is applied; otherwise, the source is returned as-is. + /// The index-aware filter predicate to apply when is true. + /// A filtered or unfiltered enumerable depending on the condition. public static IEnumerable WhereIf(this IEnumerable source, bool condition, Func predicate) { if (condition) @@ -444,6 +494,15 @@ public static IEnumerable WhereIf(this IEnumerable so return source; } + /// + /// Converts an to an with the specified page index and size. + /// + /// The type of elements in the collection. + /// The source collection to paginate. + /// The 1-based page index, or null to default to the first page. + /// The number of items per page. Must be greater than zero. + /// A paginated list containing the items for the requested page. + /// public static IPaginatedList ToPaginatedList(this ICollection query, int? pageIndex, int pageSize) { Guard.IsNotNegativeOrZero(pageSize, "pageSize"); @@ -451,6 +510,15 @@ public static IPaginatedList ToPaginatedList(this ICollection query, in return new PaginatedList(query, pageIndex, pageSize); } + /// + /// Converts an to an with the specified page index and size. + /// + /// The type of elements in the list. + /// The source list to paginate. + /// The 1-based page index, or null to default to the first page. + /// The number of items per page. Must be greater than zero. + /// A paginated list containing the items for the requested page. + /// public static IPaginatedList ToPaginatedList(this IList query, int? pageIndex, int pageSize) { Guard.IsNotNegativeOrZero(pageSize, "pageSize"); diff --git a/Src/RCommon.Core/Extensions/DateTimeExtensions.cs b/Src/RCommon.Core/Extensions/DateTimeExtensions.cs index 6c91df44..9dc5e974 100644 --- a/Src/RCommon.Core/Extensions/DateTimeExtensions.cs +++ b/Src/RCommon.Core/Extensions/DateTimeExtensions.cs @@ -6,28 +6,60 @@ namespace RCommon { + /// + /// Provides extension methods for operations including validation, + /// date range helpers, and human-readable formatting. + /// public static class DateTimeExtension { + /// + /// The minimum valid date (January 1, 1900) used by . + /// private static readonly DateTime MinDate = new DateTime(1900, 1, 1); + + /// + /// The maximum valid date (December 31, 9999 23:59:59.999) used by . + /// private static readonly DateTime MaxDate = new DateTime(9999, 12, 31, 23, 59, 59, 999); + /// + /// Determines whether the falls within the valid range (1900-01-01 to 9999-12-31). + /// + /// The to validate. + /// true if the date is within the valid range; otherwise, false. [DebuggerStepThrough] public static bool IsValid(this DateTime target) { return (target >= MinDate) && (target <= MaxDate); } + /// + /// Returns a representing the first day of the month for the given date. + /// + /// The source date. + /// A set to the first day of the month. public static DateTime FirstDayOfMonth(this DateTime dt) { return new DateTime(dt.Year, dt.Month, 1); } + /// + /// Returns a representing the last day of the month for the given date. + /// + /// The source date. + /// A set to the last day of the month. public static DateTime LastDayOfMonth(this DateTime dt) { return new DateTime(dt.Year, dt.Month, DateTime.DaysInMonth(dt.Year, dt.Month)); } + /// + /// Converts a to a human-readable relative time string (e.g., "5 minutes ago"). + /// For future dates, delegates to . + /// + /// The to humanize. + /// A human-readable string representing the relative time from now. public static string Humanize(this DateTime target) { @@ -55,6 +87,12 @@ public static string Humanize(this DateTime target) return sb.ToString(); } + /// + /// Converts a to a human-readable relative date group string + /// (e.g., "Today", "Yesterday", "Next week", "Last month"). + /// + /// The to humanize. + /// A descriptive string indicating the relative date grouping. public static string HumanizeDate(this DateTime date) { DateTime dateNow = DateTime.Now; diff --git a/Src/RCommon.Core/Extensions/DictionaryExtensions.cs b/Src/RCommon.Core/Extensions/DictionaryExtensions.cs index 44c9c171..9e0c2dc1 100644 --- a/Src/RCommon.Core/Extensions/DictionaryExtensions.cs +++ b/Src/RCommon.Core/Extensions/DictionaryExtensions.cs @@ -7,6 +7,9 @@ namespace RCommon { + /// + /// Provides extension methods for dictionary types including safe value retrieval and get-or-add semantics. + /// public static class DictionaryExtensions { @@ -18,12 +21,11 @@ public static class DictionaryExtensions /// Key /// Value of the key (or default value if key not exists) /// True if key does exists in the dictionary - internal static bool TryGetValue(this IDictionary dictionary, string key, out T value) + internal static bool TryGetValue(this IDictionary dictionary, string key, out T? value) { - object valueObj; - if (dictionary.TryGetValue(key, out valueObj) && valueObj is T) + if (dictionary.TryGetValue(key, out object? valueObj) && valueObj is T typedValue) { - value = (T)valueObj; + value = typedValue; return true; } @@ -39,10 +41,10 @@ internal static bool TryGetValue(this IDictionary dictionary, /// Type of the key /// Type of the value /// Value if found, default if can not found. - public static TValue GetOrDefault(this Dictionary dictionary, TKey key) + public static TValue? GetOrDefault(this Dictionary dictionary, TKey key) + where TKey : notnull { - TValue obj; - return dictionary.TryGetValue(key, out obj) ? obj : default; + return dictionary.TryGetValue(key, out TValue? obj) ? obj : default; } /// @@ -53,9 +55,9 @@ public static TValue GetOrDefault(this Dictionary di /// Type of the key /// Type of the value /// Value if found, default if can not found. - public static TValue GetOrDefault(this IDictionary dictionary, TKey key) + public static TValue? GetOrDefault(this IDictionary dictionary, TKey key) { - return dictionary.TryGetValue(key, out var obj) ? obj : default; + return dictionary.TryGetValue(key, out TValue? obj) ? obj : default; } /// @@ -66,9 +68,9 @@ public static TValue GetOrDefault(this IDictionary d /// Type of the key /// Type of the value /// Value if found, default if can not found. - public static TValue GetOrDefault(this IReadOnlyDictionary dictionary, TKey key) + public static TValue? GetOrDefault(this IReadOnlyDictionary dictionary, TKey key) { - return dictionary.TryGetValue(key, out var obj) ? obj : default; + return dictionary.TryGetValue(key, out TValue? obj) ? obj : default; } /// @@ -79,9 +81,10 @@ public static TValue GetOrDefault(this IReadOnlyDictionaryType of the key /// Type of the value /// Value if found, default if can not found. - public static TValue GetOrDefault(this ConcurrentDictionary dictionary, TKey key) + public static TValue? GetOrDefault(this ConcurrentDictionary dictionary, TKey key) + where TKey : notnull { - return dictionary.TryGetValue(key, out var obj) ? obj : default; + return dictionary.TryGetValue(key, out TValue? obj) ? obj : default; } /// @@ -95,10 +98,9 @@ public static TValue GetOrDefault(this ConcurrentDictionaryValue if found, default if can not found. public static TValue GetOrAdd(this IDictionary dictionary, TKey key, Func factory) { - TValue obj; - if (dictionary.TryGetValue(key, out obj)) + if (dictionary.TryGetValue(key, out TValue? obj)) { - return obj; + return obj!; } return dictionary[key] = factory(key); @@ -128,6 +130,7 @@ public static TValue GetOrAdd(this IDictionary dicti /// Type of the value /// Value if found, default if can not found. public static TValue GetOrAdd(this ConcurrentDictionary dictionary, TKey key, Func factory) + where TKey : notnull { return dictionary.GetOrAdd(key, k => factory()); } diff --git a/Src/RCommon.Core/Extensions/ExpressionExtensions.cs b/Src/RCommon.Core/Extensions/ExpressionExtensions.cs index 40370c29..d91f9d66 100644 --- a/Src/RCommon.Core/Extensions/ExpressionExtensions.cs +++ b/Src/RCommon.Core/Extensions/ExpressionExtensions.cs @@ -6,30 +6,87 @@ namespace RCommon { + /// + /// Provides extension methods to compile and invoke trees in a single call. + /// + /// + /// Each invocation compiles the expression tree, which can be expensive. Consider caching the compiled + /// delegate if performance is critical. + /// public static class ExpressionExtensions { + /// + /// Compiles and invokes a parameterless expression. + /// + /// The return type of the expression. + /// The expression to compile and invoke. + /// The result of invoking the compiled expression. public static TResult Invoke(this Expression> expr) { return expr.Compile().Invoke(); } + /// + /// Compiles and invokes an expression with one argument. + /// + /// The type of the first argument. + /// The return type of the expression. + /// The expression to compile and invoke. + /// The first argument. + /// The result of invoking the compiled expression. public static TResult Invoke(this Expression> expr, T1 arg1) { return expr.Compile().Invoke(arg1); } + /// + /// Compiles and invokes an expression with two arguments. + /// + /// The type of the first argument. + /// The type of the second argument. + /// The return type of the expression. + /// The expression to compile and invoke. + /// The first argument. + /// The second argument. + /// The result of invoking the compiled expression. public static TResult Invoke(this Expression> expr, T1 arg1, T2 arg2) { return expr.Compile().Invoke(arg1, arg2); } + /// + /// Compiles and invokes an expression with three arguments. + /// + /// The type of the first argument. + /// The type of the second argument. + /// The type of the third argument. + /// The return type of the expression. + /// The expression to compile and invoke. + /// The first argument. + /// The second argument. + /// The third argument. + /// The result of invoking the compiled expression. public static TResult Invoke( this Expression> expr, T1 arg1, T2 arg2, T3 arg3) { return expr.Compile().Invoke(arg1, arg2, arg3); } + /// + /// Compiles and invokes an expression with four arguments. + /// + /// The type of the first argument. + /// The type of the second argument. + /// The type of the third argument. + /// The type of the fourth argument. + /// The return type of the expression. + /// The expression to compile and invoke. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The result of invoking the compiled expression. public static TResult Invoke( this Expression> expr, T1 arg1, T2 arg2, T3 arg3, T4 arg4) { diff --git a/Src/RCommon.Core/Extensions/GuidExtensions.cs b/Src/RCommon.Core/Extensions/GuidExtensions.cs index 26970c78..522ecd7a 100644 --- a/Src/RCommon.Core/Extensions/GuidExtensions.cs +++ b/Src/RCommon.Core/Extensions/GuidExtensions.cs @@ -6,9 +6,17 @@ namespace RCommon { + /// + /// Provides extension methods for operations. + /// public static class GuidExtension { + /// + /// Determines whether the is equal to . + /// + /// The to check. + /// true if the GUID is empty; otherwise, false. [DebuggerStepThrough] public static bool IsEmpty(this Guid target) { diff --git a/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs b/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs index f84b5559..e4c3e87f 100644 --- a/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs +++ b/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs @@ -6,6 +6,10 @@ namespace RCommon { + /// + /// Provides extension methods for including safe value retrieval + /// and conversion to . + /// public static class IDataReaderExtensions { @@ -38,6 +42,13 @@ public static object GetValue(this IDataReader dr, int index, object defaultValu return rv; } + /// + /// Acquires the value from a row of an based on the column name. + /// + /// A populated . + /// The name of the column to retrieve the value from. + /// The default value to return if the column is not found or is . + /// The value of the column, or if not found or null. public static object GetValue(this IDataReader dr, string columnName, object defaultValue) { object rv = defaultValue; @@ -68,24 +79,27 @@ public static object GetValue(this IDataReader dr, string columnName, object def /// This method does not close the IDataReader. You will have to. public static DataTable ToDataTable(this IDataReader dr) { - DataTable dtSchema = dr.GetSchemaTable(); + DataTable? dtSchema = dr.GetSchemaTable(); DataTable dtData = new DataTable(); DataColumn dc; DataRow row; System.Collections.ArrayList al = new System.Collections.ArrayList(); + if (dtSchema == null) return dtData; + // Populate the Column Information for (int i = 0; i < dtSchema.Rows.Count; i++) { dc = new DataColumn(); - if (!dtData.Columns.Contains(dtSchema.Rows[i]["ColumnName"].ToString())) + var columnName = dtSchema.Rows[i]["ColumnName"]?.ToString(); + if (columnName != null && !dtData.Columns.Contains(columnName)) { - dc.ColumnName = dtSchema.Rows[i]["ColumnName"].ToString(); + dc.ColumnName = columnName; dc.Unique = Convert.ToBoolean(dtSchema.Rows[i]["IsUnique"]); dc.AllowDBNull = Convert.ToBoolean(dtSchema.Rows[i]["AllowDBNull"]); dc.ReadOnly = Convert.ToBoolean(dtSchema.Rows[i]["IsReadOnly"]); - dc.DataType = (Type)dtSchema.Rows[i]["DataType"]; + dc.DataType = (Type?)dtSchema.Rows[i]["DataType"] ?? typeof(object); al.Add(dc.ColumnName); dtData.Columns.Add(dc); @@ -99,7 +113,7 @@ public static DataTable ToDataTable(this IDataReader dr) for (int i = 0; i < al.Count; i++) { - row[((System.String)al[i])] = dr[(System.String)al[i]]; + row[((string)al[i]!)] = dr[(string)al[i]!]; } dtData.Rows.Add(row); @@ -119,20 +133,23 @@ public static DataTable ToDataTable(this IDataReader dr, bool destroyReader) { try { - DataTable dtSchema = dr.GetSchemaTable(); + DataTable? dtSchema = dr.GetSchemaTable(); DataTable dtData = new DataTable(); DataColumn dc; DataRow row; System.Collections.ArrayList al = new System.Collections.ArrayList(); + if (dtSchema == null) return dtData; + // Populate the Column Information for (int i = 0; i < dtSchema.Rows.Count; i++) { dc = new DataColumn(); - if (!dtData.Columns.Contains(dtSchema.Rows[i]["ColumnName"].ToString())) + var columnName2 = dtSchema.Rows[i]["ColumnName"]?.ToString(); + if (columnName2 != null && !dtData.Columns.Contains(columnName2)) { - dc.ColumnName = dtSchema.Rows[i]["ColumnName"].ToString(); + dc.ColumnName = columnName2; dc.Unique = Convert.ToBoolean(dtSchema.Rows[i]["IsUnique"]); dc.AllowDBNull = Convert.ToBoolean(dtSchema.Rows[i]["AllowDBNull"]); dc.ReadOnly = Convert.ToBoolean(dtSchema.Rows[i]["IsReadOnly"]); @@ -148,7 +165,7 @@ public static DataTable ToDataTable(this IDataReader dr, bool destroyReader) for (int i = 0; i < al.Count; i++) { - row[((System.String)al[i])] = dr[(System.String)al[i]]; + row[((string)al[i]!)] = dr[(string)al[i]!]; } dtData.Rows.Add(row); diff --git a/Src/RCommon.Core/Extensions/ILoggerExtensions.cs b/Src/RCommon.Core/Extensions/ILoggerExtensions.cs index 519bb0ae..cf52b288 100644 --- a/Src/RCommon.Core/Extensions/ILoggerExtensions.cs +++ b/Src/RCommon.Core/Extensions/ILoggerExtensions.cs @@ -6,12 +6,22 @@ namespace Microsoft.Extensions.Logging { + /// + /// Provides extension methods for that add optional console output alongside standard logging. + /// public static class ILoggerExtensions { + /// + /// Logs an informational message with optional console output. + /// + /// The logger instance. + /// The log message template. + /// The message format parameters. + /// If true, also writes the message to the console. public static void LogInformation(this ILogger logger, string? message, object[]? @params, bool outputToConsole = false) { - logger.LogInformation(message, @params); + logger.LogInformation(message, @params ?? System.Array.Empty()); if (outputToConsole) { @@ -19,9 +29,17 @@ public static void LogInformation(this ILogger logger, string? message, object[] } } + /// + /// Logs an informational message with an event ID and optional console output. + /// + /// The logger instance. + /// The event ID associated with the log entry. + /// The log message template. + /// The message format parameters. + /// If true, also writes the message to the console. public static void LogInformation(this ILogger logger, EventId eventId, string? message, object[]? @params, bool outputToConsole = false) { - logger.LogInformation(eventId, message, @params); + logger.LogInformation(eventId, message, @params ?? System.Array.Empty()); if (outputToConsole) { @@ -29,9 +47,17 @@ public static void LogInformation(this ILogger logger, EventId eventId, string? } } + /// + /// Logs an informational message with an exception and optional console output. + /// + /// The logger instance. + /// The exception associated with the log entry. + /// The log message template. + /// The message format parameters. + /// If true, also writes the message to the console. public static void LogInformation(this ILogger logger, Exception exception, string? message, object[]? @params, bool outputToConsole = false) { - logger.LogInformation(exception, message, @params); + logger.LogInformation(exception, message, @params ?? System.Array.Empty()); if (outputToConsole) { @@ -39,9 +65,18 @@ public static void LogInformation(this ILogger logger, Exception exception, stri } } + /// + /// Logs an informational message with an event ID, exception, and optional console output. + /// + /// The logger instance. + /// The event ID associated with the log entry. + /// The exception associated with the log entry. + /// The log message template. + /// The message format parameters. + /// If true, also writes the message to the console. public static void LogInformation(this ILogger logger, EventId eventId, Exception exception, string? message, object[]? @params, bool outputToConsole = false) { - logger.LogInformation(eventId, exception, message, @params); + logger.LogInformation(eventId, exception, message, @params ?? System.Array.Empty()); if (outputToConsole) { @@ -49,9 +84,14 @@ public static void LogInformation(this ILogger logger, EventId eventId, Exceptio } } + /// + /// Writes a formatted message to the console using . + /// + /// The message template. + /// The format parameters. private static void OutputToConsole(string? message, object[]? @params) { - System.Console.WriteLine(message, @params); + System.Console.WriteLine(message ?? string.Empty, @params ?? System.Array.Empty()); } } } diff --git a/Src/RCommon.Core/Extensions/IQueryableExtensions.cs b/Src/RCommon.Core/Extensions/IQueryableExtensions.cs index 280d1a86..de5a27c3 100644 --- a/Src/RCommon.Core/Extensions/IQueryableExtensions.cs +++ b/Src/RCommon.Core/Extensions/IQueryableExtensions.cs @@ -10,19 +10,38 @@ namespace RCommon { + /// + /// Provides extension methods for including dynamic ordering, + /// conditional filtering, LIKE-style queries, and pagination. + /// public static class IQueryableExtensions { + /// + /// Dynamically orders an by a property name specified as a string. + /// + /// The entity type. + /// The queryable source. + /// The name of the property to order by. + /// If true, orders descending; otherwise, ascending. + /// An ordered . + /// + /// Builds an expression tree at runtime to call OrderBy or OrderByDescending + /// on the query provider using the specified property name. + /// public static IQueryable OrderBy(this IQueryable source, string orderByProperty, bool desc) where TEntity : class { - + // Determine which Queryable method to call based on sort direction string command = desc ? "OrderByDescending" : "OrderBy"; var type = typeof(TEntity); - var property = type.GetProperty(orderByProperty); + var property = type.GetProperty(orderByProperty) + ?? throw new ArgumentException($"Property '{orderByProperty}' not found on type '{type.FullName}'.", nameof(orderByProperty)); var parameter = Expression.Parameter(type, "p"); + // Build a member access expression for the property (e.g., p.PropertyName) var propertyAccess = Expression.MakeMemberAccess(parameter, property); var orderByExpression = Expression.Lambda(propertyAccess, parameter); + // Create a method call expression to Queryable.OrderBy/OrderByDescending var resultExpression = Expression.Call(typeof(Queryable), command, new Type[] { type, property.PropertyType }, source.Expression, Expression.Quote(orderByExpression)); return source.Provider.CreateQuery(resultExpression); @@ -31,6 +50,15 @@ public static IQueryable OrderBy(this IQueryable sour + /// + /// Conditionally filters an based on a boolean condition. + /// If the condition is false, the source is returned unfiltered. + /// + /// The type of elements in the source. + /// The queryable source to filter. + /// If true, the predicate is applied; otherwise, the source is returned as-is. + /// The filter expression to apply when is true. + /// A filtered or unfiltered queryable depending on the condition. public static IQueryable WhereIf(this IQueryable source, bool condition, Expression> predicate) { if (condition) @@ -39,6 +67,15 @@ public static IQueryable WhereIf(this IQueryable sour return source; } + /// + /// Conditionally filters an based on a boolean condition, using an index-aware predicate. + /// If the condition is false, the source is returned unfiltered. + /// + /// The type of elements in the source. + /// The queryable source to filter. + /// If true, the predicate is applied; otherwise, the source is returned as-is. + /// The index-aware filter expression to apply when is true. + /// A filtered or unfiltered queryable depending on the condition. public static IQueryable WhereIf(this IQueryable source, bool condition, Expression> predicate) { if (condition) @@ -47,11 +84,29 @@ public static IQueryable WhereIf(this IQueryable sour return source; } + /// + /// Filters an using SQL LIKE-style matching with the % wildcard character. + /// + /// The type of elements in the source. + /// The queryable source to filter. + /// An expression selecting the string property to match against. + /// The pattern to match, using % as the wildcard character. + /// A filtered . public static IQueryable WhereLike(this IQueryable source, Expression> valueSelector, string value) { return source.Where(BuildLikeExpression(valueSelector, value, '%')); } + /// + /// Builds an expression tree that mimics SQL LIKE behavior by mapping wildcard positions + /// to , , or . + /// + /// The type of element in the source. + /// An expression selecting the string property to match against. + /// The pattern to match, with wildcards. + /// The wildcard character (typically %). + /// A predicate expression representing the LIKE comparison. + /// Thrown when is null. public static Expression> BuildLikeExpression(Expression> valueSelector, string value, char wildcard) { if (valueSelector == null) @@ -66,6 +121,13 @@ public static Expression> BuildLikeExpression(Exp return Expression.Lambda>(body, parameter); } + /// + /// Determines the appropriate method (Contains, StartsWith, or EndsWith) + /// based on the position of wildcard characters in the value. + /// + /// The pattern string containing wildcards. + /// The wildcard character to detect. + /// The for the chosen string comparison method. private static MethodInfo GetLikeMethod(string value, char wildcard) { var methodName = "Contains"; @@ -87,9 +149,19 @@ private static MethodInfo GetLikeMethod(string value, char wildcard) } var stringType = typeof(string); - return stringType.GetMethod(methodName, new Type[] { stringType }); + return stringType.GetMethod(methodName, new Type[] { stringType }) + ?? throw new InvalidOperationException($"Method '{methodName}' not found on type 'System.String'."); } + /// + /// Converts an to an with the specified page number and size. + /// + /// The type of elements in the queryable. + /// The queryable source to paginate. + /// The 1-based page number. Defaults to 1. + /// The number of items per page. Defaults to 10. Must be greater than zero. + /// A paginated list containing the items for the requested page. + /// public static IPaginatedList ToPaginatedList(this IQueryable source, int pageNumber = 1, int pageSize = 10) { Guard.IsNotNegativeOrZero(pageSize, "pageSize"); diff --git a/Src/RCommon.Core/Extensions/ObjectExtensions.cs b/Src/RCommon.Core/Extensions/ObjectExtensions.cs index 0758339d..e4d32409 100644 --- a/Src/RCommon.Core/Extensions/ObjectExtensions.cs +++ b/Src/RCommon.Core/Extensions/ObjectExtensions.cs @@ -9,6 +9,10 @@ namespace RCommon { + /// + /// Provides general-purpose extension methods for including casting, + /// type conversion, reflection-based property access, conditional execution, and object graph traversal. + /// public static class ObjectExtensions { /// @@ -24,8 +28,8 @@ public static bool BinaryEquals(this object binaryValue1, object binaryValue2) return true; } - byte[] array1 = binaryValue1 as byte[]; - byte[] array2 = binaryValue2 as byte[]; + byte[]? array1 = binaryValue1 as byte[]; + byte[]? array2 = binaryValue2 as byte[]; if (array1 != null && array2 != null) { @@ -55,14 +59,14 @@ public static bool BinaryEquals(this object binaryValue1, object binaryValue2) /// The source object from which the property is to be fetched /// The name of the property /// - public static T GetPropertyValueWithReflection(this object sourceObject, + public static T? GetPropertyValueWithReflection(this object sourceObject, string propertyName) { Guard.Against(sourceObject == null, "sourceObject cannot be null"); Guard.Against(string.IsNullOrEmpty(propertyName), "propertyName, cannot be null or empty"); BindingFlags eFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - PropertyInfo propertyInfo = sourceObject.GetType().GetProperty(propertyName, eFlags); + PropertyInfo? propertyInfo = sourceObject!.GetType().GetProperty(propertyName, eFlags); if (propertyInfo == null) { @@ -70,7 +74,12 @@ public static T GetPropertyValueWithReflection(this object sourceObject, sourceObject.GetType().Name); } - return (T)propertyInfo.GetValue(sourceObject, null); + object? value = propertyInfo.GetValue(sourceObject, null); + if (value == null) + { + return default; + } + return (T)value; } /// @@ -81,13 +90,17 @@ public static T GetPropertyValueWithReflection(this object sourceObject, /// the name of the property /// the value of the property public static void SetPropertyValueWithReflection(this object anObject, - string propertyName, object propertyValue) + string propertyName, object? propertyValue) { Guard.Against(anObject == null, "anObject cannot be null"); Guard.Against(string.IsNullOrEmpty(propertyName), "propertyName, cannot be null or empty"); BindingFlags eFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - PropertyInfo propertyInfo = anObject.GetType().GetProperty(propertyName, eFlags); + PropertyInfo? propertyInfo = anObject!.GetType().GetProperty(propertyName, eFlags); + + if (propertyInfo == null) + throw new ApplicationException("Cannot find property [" + propertyName + "] in object type: " + + anObject.GetType().FullName); Type dataType = propertyInfo.PropertyType; if (!(dataType.IsGenericType && (dataType.GetGenericTypeDefinition() == typeof(Nullable<>)))) @@ -97,9 +110,6 @@ public static void SetPropertyValueWithReflection(this object anObject, throw new ArgumentNullException("propertyValue"); } } - if (propertyInfo == null) - throw new ApplicationException("Cannot find property [" + propertyName + "] in object type: " + - anObject.GetType().FullName); propertyInfo.SetValue(anObject, propertyValue, null); } @@ -110,7 +120,7 @@ public static void SetPropertyValueWithReflection(this object anObject, ///The user supplied key, if any. ///The type for which the key is built. ///string. - public static string BuildFullKey(this object userKey) + public static string? BuildFullKey(this object userKey) { if (userKey == null) return typeof(T).FullName; @@ -140,7 +150,7 @@ public static T To(this object obj) { if (typeof(T) == typeof(Guid)) { - return (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(obj.ToString()); + return (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(obj.ToString()!)!; } return (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); @@ -213,6 +223,12 @@ public static T If(this T obj, bool condition, Action action) return obj; } + /// + /// Gets a human-readable generic type name for the object (e.g., "List<String>" instead of "List`1"). + /// + /// The object whose type name is retrieved. + /// A formatted generic type name string. + /// public static string GetGenericTypeName(this object @object) { return @object.GetType().GetGenericTypeName(); diff --git a/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs b/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs index 286b359b..7eea61d0 100644 --- a/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs @@ -10,6 +10,10 @@ namespace RCommon { + /// + /// Provides extension methods for to bootstrap RCommon services, + /// register hosted services, and provide diagnostic output of service registrations. + /// public static class ServiceCollectionExtensions { /// @@ -24,6 +28,11 @@ public static IRCommonBuilder AddRCommon(this IServiceCollection services) return config; } + /// + /// Registers as an singleton if the type implements the interface. + /// + /// The service type to check and potentially register as a hosted service. + /// The service collection to register with. public static void AddHostedServiceIfSupported(this IServiceCollection services) where T : class { @@ -33,17 +42,28 @@ public static void AddHostedServiceIfSupported(this IServiceCollection servic } } + /// + /// Creates a snapshot collection of all instances currently registered in the service collection. + /// + /// The service collection to extract descriptors from. + /// A collection of instances. public static ICollection GenerateServiceDescriptors(this IServiceCollection services) { ICollection returnItems = new List(services); return returnItems; } + /// + /// Generates a formatted string listing all service descriptors in the service collection, ordered by service type name. + /// Useful for diagnostics and debugging IoC registrations. + /// + /// The service collection to describe. + /// A multi-line string describing each service descriptor. public static string GenerateServiceDescriptorsString(this IServiceCollection services) { StringBuilder sb = new StringBuilder(); IEnumerable sds = GenerateServiceDescriptors(services).AsEnumerable() - .OrderBy(o => o.ServiceType.FullName); + .OrderBy(o => o.ServiceType.FullName ?? string.Empty); foreach (ServiceDescriptor sd in sds) { sb.Append($"(ServiceDescriptor):"); @@ -57,6 +77,12 @@ public static string GenerateServiceDescriptorsString(this IServiceCollection se return returnValue; } + /// + /// Generates a formatted string listing duplicate service registrations in the service collection. + /// Identifies registrations where the same service type, lifetime, and implementation type appear more than once. + /// + /// The service collection to check for duplicates. + /// A multi-line string describing each duplicate registration, or empty if none found. public static string GeneratePossibleDuplicatesServiceDescriptorsString(this IServiceCollection services) { StringBuilder sb = new StringBuilder(); @@ -96,10 +122,17 @@ where grp.Count() > 1 return returnValue; } + /// + /// Logs all service descriptors and any duplicate registrations to both the logger and the console. + /// Duplicates are logged at warning level. + /// + /// The type used to create the logger category. + /// The service collection to log. + /// The logger factory used to create the logger. public static void LogServiceDescriptors(this IServiceCollection services, ILoggerFactory loggerFactory) { string iocDebugging = services.GenerateServiceDescriptorsString(); - Func logMsgStringFunc = (a, b) => iocDebugging; + Func logMsgStringFunc = (a, b) => iocDebugging; ILogger logger = loggerFactory.CreateLogger(); logger.Log( LogLevel.Information, @@ -112,7 +145,7 @@ public static void LogServiceDescriptors(this IServiceCollection services, IL string iocPossibleDuplicates = GeneratePossibleDuplicatesServiceDescriptorsString(services); if (!string.IsNullOrWhiteSpace(iocPossibleDuplicates)) { - Func logMsgStringDuplicatesFunc = (a, b) => iocPossibleDuplicates; + Func logMsgStringDuplicatesFunc = (a, b) => iocPossibleDuplicates; logger.Log( LogLevel.Warning, ushort.MaxValue, @@ -123,15 +156,22 @@ public static void LogServiceDescriptors(this IServiceCollection services, IL } } + /// + /// Internal data holder for tracking duplicate IoC registrations during diagnostic analysis. + /// [DebuggerDisplay("ServiceTypeFullName='{ServiceTypeFullName}', Lifetime='{Lifetime}', ImplementationTypeFullName='{ImplementationTypeFullName}', DuplicateCount='{DuplicateCount}'")] private sealed class DuplicateIocRegistrationHolder { - public string ServiceTypeFullName { get; set; } + /// Gets or sets the full name of the service type. + public string? ServiceTypeFullName { get; set; } + /// Gets or sets the service lifetime. public ServiceLifetime Lifetime { get; set; } - public string ImplementationTypeFullName { get; set; } + /// Gets or sets the full name of the implementation type. + public string? ImplementationTypeFullName { get; set; } + /// Gets or sets the number of duplicate registrations found. public int DuplicateCount { get; set; } } } diff --git a/Src/RCommon.Core/Extensions/StreamExtensions.cs b/Src/RCommon.Core/Extensions/StreamExtensions.cs index 5ab7369d..61284741 100644 --- a/Src/RCommon.Core/Extensions/StreamExtensions.cs +++ b/Src/RCommon.Core/Extensions/StreamExtensions.cs @@ -8,9 +8,18 @@ namespace RCommon { + /// + /// Provides extension methods for including reading all bytes and async copy operations. + /// public static class StreamExtensions { + /// + /// Reads all bytes from the stream into a byte array. + /// Resets the stream position to the beginning if the stream supports seeking. + /// + /// The stream to read from. + /// A byte array containing the entire contents of the stream. public static byte[] GetAllBytes(this Stream stream) { using (var memoryStream = new MemoryStream()) @@ -24,6 +33,13 @@ public static byte[] GetAllBytes(this Stream stream) } } + /// + /// Asynchronously reads all bytes from the stream into a byte array. + /// Resets the stream position to the beginning if the stream supports seeking. + /// + /// The stream to read from. + /// A token to monitor for cancellation requests. + /// A task that resolves to a byte array containing the entire contents of the stream. public static async Task GetAllBytesAsync(this Stream stream, CancellationToken cancellationToken = default) { using (var memoryStream = new MemoryStream()) @@ -37,6 +53,14 @@ public static async Task GetAllBytesAsync(this Stream stream, Cancellati } } + /// + /// Asynchronously copies the stream to a destination stream with cancellation support. + /// Resets the stream position to the beginning if the stream supports seeking. + /// + /// The source stream to copy from. + /// The destination stream to copy to. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous copy operation. public static Task CopyToAsync(this Stream stream, Stream destination, CancellationToken cancellationToken) { if (stream.CanSeek) diff --git a/Src/RCommon.Core/Extensions/StringExtensions.cs b/Src/RCommon.Core/Extensions/StringExtensions.cs index 36c6f95d..21cb6d58 100644 --- a/Src/RCommon.Core/Extensions/StringExtensions.cs +++ b/Src/RCommon.Core/Extensions/StringExtensions.cs @@ -11,6 +11,10 @@ namespace RCommon { + /// + /// Provides extension methods for operations including validation, formatting, + /// encoding, hashing, case conversion, and various string manipulation utilities. + /// public static class StringExtension { private static readonly Regex WebUrlExpression = new Regex(@"(http|https)://([\w-]+\.)+[\w-]+(/[\w- ./?%&=]*)?", RegexOptions.Singleline | RegexOptions.Compiled); @@ -19,24 +23,45 @@ public static class StringExtension private static readonly char[] IllegalUrlCharacters = new[] { ';', '/', '\\', '?', ':', '@', '&', '=', '+', '$', ',', '<', '>', '#', '%', '.', '!', '*', '\'', '"', '(', ')', '[', ']', '{', '}', '|', '^', '`', '~', '–', '‘', '’', '“', '”', '»', '«' }; + /// + /// Determines whether the string is a valid HTTP or HTTPS web URL. + /// + /// The string to test. + /// true if the string is a valid web URL; otherwise, false. [DebuggerStepThrough] public static bool IsWebUrl(this string target) { return !string.IsNullOrEmpty(target) && WebUrlExpression.IsMatch(target); } + /// + /// Determines whether the string is a valid email address. + /// + /// The string to test. + /// true if the string is a valid email address; otherwise, false. [DebuggerStepThrough] public static bool IsEmail(this string target) { return !string.IsNullOrEmpty(target) && EmailExpression.IsMatch(target); } + /// + /// Returns the string trimmed, or if the string is null. + /// + /// The string to make null-safe. + /// The trimmed string, or empty string if null. [DebuggerStepThrough] - public static string NullSafe(this string target) + public static string NullSafe(this string? target) { return (target ?? string.Empty).Trim(); } + /// + /// Formats the string using the current culture with the specified arguments. + /// + /// The format string template. + /// The arguments to substitute into the format string. + /// The formatted string. [DebuggerStepThrough] public static string FormatWith(this string target, params object[] args) { @@ -45,6 +70,11 @@ public static string FormatWith(this string target, params object[] args) return string.Format(Constants.CurrentCulture, target, args); } + /// + /// Computes an MD5 hash of the string and returns it as a Base64-encoded string. + /// + /// The string to hash. + /// A Base64-encoded MD5 hash of the string. [DebuggerStepThrough] public static string Hash(this string target) { @@ -59,6 +89,12 @@ public static string Hash(this string target) } } + /// + /// Truncates the string at the specified index and appends an ellipsis ("...") if the string exceeds that length. + /// + /// The string to truncate. + /// The maximum length including the ellipsis. + /// The original string if shorter than , or the truncated string with ellipsis. [DebuggerStepThrough] public static string WrapAt(this string target, int index) { @@ -70,12 +106,22 @@ public static string WrapAt(this string target, int index) return (target.Length <= index) ? target : string.Concat(target.Substring(0, index - DotCount), new string('.', DotCount)); } + /// + /// Removes all HTML tags from the string. + /// + /// The string to strip HTML from. + /// The string with all HTML tags removed. [DebuggerStepThrough] public static string StripHtml(this string target) { return StripHTMLExpression.Replace(target, string.Empty); } + /// + /// Converts a 22-character URL-safe Base64-encoded string back to a . + /// + /// The 22-character Base64-encoded GUID string (with - and _ as safe characters). + /// The decoded , or if the input is invalid. [DebuggerStepThrough] public static Guid ToGuid(this string target) { @@ -99,6 +145,13 @@ public static Guid ToGuid(this string target) return result; } + /// + /// Converts the string to an enum value, returning a default value if conversion fails. + /// + /// The enum type. + /// The string to convert. + /// The default value to return if parsing fails. + /// The parsed enum value, or if parsing fails. [DebuggerStepThrough] public static T ToEnum(this string target, T defaultValue) where T : IComparable, IFormattable { @@ -118,6 +171,11 @@ public static T ToEnum(this string target, T defaultValue) where T : ICompara return convertedValue; } + /// + /// Removes illegal URL characters from the string and replaces spaces with hyphens to produce a URL-safe string. + /// + /// The string to convert. + /// A URL-safe string, or the original string if it is null or empty. [DebuggerStepThrough] public static string ToLegalUrl(this string target) { @@ -146,36 +204,68 @@ public static string ToLegalUrl(this string target) return target; } + /// + /// URL-encodes the string using . + /// + /// The string to encode. + /// The URL-encoded string. [DebuggerStepThrough] - public static string UrlEncode(this string target) + public static string? UrlEncode(this string target) { return HttpUtility.UrlEncode(target); } + /// + /// URL-decodes the string using . + /// + /// The string to decode. + /// The URL-decoded string. [DebuggerStepThrough] - public static string UrlDecode(this string target) + public static string? UrlDecode(this string target) { return HttpUtility.UrlDecode(target); } + /// + /// HTML-attribute-encodes the string using . + /// + /// The string to encode. + /// The HTML-attribute-encoded string. [DebuggerStepThrough] public static string AttributeEncode(this string target) { return HttpUtility.HtmlAttributeEncode(target); } + /// + /// HTML-encodes the string using . + /// + /// The string to encode. + /// The HTML-encoded string. [DebuggerStepThrough] public static string HtmlEncode(this string target) { return HttpUtility.HtmlEncode(target); } + /// + /// HTML-decodes the string using . + /// + /// The string to decode. + /// The HTML-decoded string. [DebuggerStepThrough] public static string HtmlDecode(this string target) { return HttpUtility.HtmlDecode(target); } + /// + /// Replaces all occurrences of each string in with . + /// + /// The string to perform replacements on. + /// The collection of strings to replace. + /// The replacement string. + /// The modified string with all replacements applied. public static string Replace(this string target, ICollection oldValues, string newValue) { oldValues.ForEach(oldValue => target = target.Replace(oldValue, newValue)); @@ -187,7 +277,7 @@ public static string Replace(this string target, ICollection oldValues, /// /// the input string to be converted /// The converted string - public static string ToNullIfEmptyOrBlank(this string inputString) + public static string? ToNullIfEmptyOrBlank(this string? inputString) { if (inputString == null || (inputString = inputString.Trim()).Length == 0) return null; @@ -199,7 +289,7 @@ public static string ToNullIfEmptyOrBlank(this string inputString) /// /// /// - public static bool IsNullOrEmptyOrBlank(this string inputString) + public static bool IsNullOrEmptyOrBlank(this string? inputString) { return (inputString == null || inputString.Trim().Length == 0); } @@ -411,11 +501,24 @@ public static string Limit(this string str, int characterCount) else return str.Substring(0, characterCount).TrimEnd(' '); } + /// + /// Compares two strings for equality, ignoring case by default. + /// + /// The first string. + /// The string to compare against. + /// true if the strings are equal (case-insensitive); otherwise, false. public static bool IsTheSameAs(this string target, string stringToCompare) { return IsTheSameAs(target, stringToCompare, true); } + /// + /// Compares two strings for equality with optional case sensitivity. + /// + /// The first string. + /// The string to compare against. + /// If true, performs a case-insensitive comparison. + /// true if the strings are equal; otherwise, false. public static bool IsTheSameAs(this string target, string stringToCompare, bool ignoreCase) { return string.Compare(target, stringToCompare, ignoreCase) == 0; @@ -441,11 +544,23 @@ public static string ToSlug(this string name) return sb.ToString().Trim('-'); } + /// + /// Returns the plural form of the string using the . + /// + /// The word to pluralize. + /// The pluralized form of the word. + /// public static string Pluralize(this string target) { return Inflector.Pluralize(target); } + /// + /// Conditionally pluralizes the string based on the result of a boolean expression. + /// + /// The word to conditionally pluralize. + /// An expression that returns true if the word should be pluralized. + /// The pluralized form if the expression evaluates to true; otherwise, the original string. public static string PluralizeIf(this string target, Expression> expression) { if (expression.Invoke()) @@ -491,7 +606,7 @@ public static string EnsureStartsWith(this string str, char c, StringComparison /// /// Indicates whether this string is null or an System.String.Empty string. /// - public static bool IsNullOrEmpty(this string str) + public static bool IsNullOrEmpty(this string? str) { return string.IsNullOrEmpty(str); } @@ -499,7 +614,7 @@ public static bool IsNullOrEmpty(this string str) /// /// indicates whether this string is null, empty, or consists only of white-space characters. /// - public static bool IsNullOrWhiteSpace(this string str) + public static bool IsNullOrWhiteSpace(this string? str) { return string.IsNullOrWhiteSpace(str); } @@ -638,6 +753,14 @@ public static string RemovePreFix(this string str, StringComparison comparisonTy return str; } + /// + /// Replaces the first occurrence of a search string with a replacement string. + /// + /// The source string. + /// The string to find. + /// The replacement string. + /// The string comparison type to use for finding the search string. + /// The string with the first occurrence replaced, or the original string if not found. public static string ReplaceFirst(this string str, string search, string replace, StringComparison comparisonType = StringComparison.Ordinal) { Guard.IsNotNull(str, nameof(str)); @@ -860,6 +983,11 @@ public static T ToEnum(this string value, bool ignoreCase) return (T)Enum.Parse(typeof(T), value, ignoreCase); } + /// + /// Computes the MD5 hash of the string and returns it as an uppercase hexadecimal string. + /// + /// The string to hash. + /// The MD5 hash as an uppercase hexadecimal string. public static string ToMd5(this string str) { using (var md5 = MD5.Create()) @@ -902,7 +1030,7 @@ public static string ToPascalCase(this string str, bool useCurrentCulture = fals /// Gets a substring of a string from beginning of the string if it exceeds maximum length. /// /// Thrown if is null - public static string Truncate(this string str, int maxLength) + public static string? Truncate(this string? str, int maxLength) { if (str == null) { @@ -921,7 +1049,7 @@ public static string Truncate(this string str, int maxLength) /// Gets a substring of a string from Ending of the string if it exceeds maximum length. /// /// Thrown if is null - public static string TruncateFromBeginning(this string str, int maxLength) + public static string? TruncateFromBeginning(this string? str, int maxLength) { if (str == null) { @@ -942,7 +1070,7 @@ public static string TruncateFromBeginning(this string str, int maxLength) /// Returning string can not be longer than maxLength. /// /// Thrown if is null - public static string TruncateWithPostfix(this string str, int maxLength) + public static string? TruncateWithPostfix(this string? str, int maxLength) { return TruncateWithPostfix(str, maxLength, "..."); } @@ -953,7 +1081,7 @@ public static string TruncateWithPostfix(this string str, int maxLength) /// Returning string can not be longer than maxLength. /// /// Thrown if is null - public static string TruncateWithPostfix(this string str, int maxLength, string postfix) + public static string? TruncateWithPostfix(this string? str, int maxLength, string postfix) { if (str == null) { @@ -997,6 +1125,12 @@ public static byte[] GetBytes(this string str, Encoding encoding) return encoding.GetBytes(str); } + /// + /// Determines whether all letter characters in the input string are uppercase. + /// Non-letter characters are ignored. + /// + /// The string to check. + /// true if all letter characters are uppercase; otherwise, false. private static bool IsAllUpperCase(string input) { for (int i = 0; i < input.Length; i++) diff --git a/Src/RCommon.Core/Extensions/TypeExtensions.cs b/Src/RCommon.Core/Extensions/TypeExtensions.cs index 69ce9a11..3afb9dd5 100644 --- a/Src/RCommon.Core/Extensions/TypeExtensions.cs +++ b/Src/RCommon.Core/Extensions/TypeExtensions.cs @@ -32,8 +32,18 @@ namespace RCommon { + /// + /// Provides extension methods for including human-readable type name formatting, + /// cache key generation, constructor inspection, and assignability checks. + /// public static class TypeExtensions { + /// + /// Gets a human-readable generic type name (e.g., "List<String>" instead of "List`1"). + /// For non-generic types, returns . + /// + /// The type to get the name for. + /// A formatted type name string. public static string GetGenericTypeName(this Type type) { var typeName = string.Empty; @@ -51,8 +61,17 @@ public static string GetGenericTypeName(this Type type) return typeName; } + /// + /// Thread-safe cache for pretty-printed type names to avoid recomputation. + /// private static readonly ConcurrentDictionary PrettyPrintCache = new ConcurrentDictionary(); + /// + /// Returns a human-readable representation of the type, including nested generic arguments. + /// Results are cached for performance. + /// + /// The type to format. + /// A pretty-printed type name string. public static string PrettyPrint(this Type type) { return PrettyPrintCache.GetOrAdd( @@ -70,7 +89,17 @@ public static string PrettyPrint(this Type type) }); } + /// + /// Thread-safe cache for type cache key strings. + /// private static readonly ConcurrentDictionary TypeCacheKeys = new ConcurrentDictionary(); + + /// + /// Gets a unique cache key string for the type, combining its pretty-printed name with its hash code. + /// Results are cached for performance. + /// + /// The type to generate a cache key for. + /// A cache key string in the format "TypeName[hash: hashCode]". public static string GetCacheKey(this Type type) { return TypeCacheKeys.GetOrAdd( @@ -78,6 +107,13 @@ public static string GetCacheKey(this Type type) t => $"{t.PrettyPrint()}[hash: {t.GetHashCode()}]"); } + /// + /// Recursively builds a pretty-printed type name, expanding generic arguments up to a depth limit of 3 + /// to prevent infinite recursion with self-referencing generic types. + /// + /// The type to format. + /// The current recursion depth. + /// A formatted type name string. private static string PrettyPrintRecursive(Type type, int depth) { if (depth > 3) @@ -97,6 +133,12 @@ private static string PrettyPrintRecursive(Type type, int depth) : $"{nameParts[0]}<{string.Join(",", genericArguments.Select(t => PrettyPrintRecursive(t, depth + 1)))}>"; } + /// + /// Determines whether the type has any constructor with a parameter matching the given predicate. + /// + /// The type to inspect. + /// A predicate to test each constructor parameter type against. + /// true if any constructor parameter matches the predicate; otherwise, false. public static bool HasConstructorParameterOfType(this Type type, Predicate predicate) { return type.GetTypeInfo().GetConstructors() @@ -104,6 +146,12 @@ public static bool HasConstructorParameterOfType(this Type type, Predicate .Any(p => predicate(p.ParameterType))); } + /// + /// Determines whether the type is assignable to . + /// + /// The target type to check assignability against. + /// The type to check. + /// true if instances of can be assigned to ; otherwise, false. public static bool IsAssignableTo(this Type type) { return typeof(T).GetTypeInfo().IsAssignableFrom(type); diff --git a/Src/RCommon.Core/GeneralException.cs b/Src/RCommon.Core/GeneralException.cs index 4a8e836a..d03eec29 100644 --- a/Src/RCommon.Core/GeneralException.cs +++ b/Src/RCommon.Core/GeneralException.cs @@ -6,31 +6,54 @@ namespace RCommon { + /// + /// Defines severity levels for exceptions, used to classify the impact of an error. + /// public enum SeverityOptions : int { + /// Low severity; typically informational or easily recoverable. Low = 1, + /// Medium severity; may require attention but is not critical. Medium = 2, + /// High severity; default level indicating a significant error. High = 3, + /// Critical severity; indicates a fatal or system-level failure. Critical = 4 } + /// + /// A general-purpose exception that supports severity classification and parameterized message formatting. + /// Extends to include environment diagnostic information. + /// [Serializable] public class GeneralException : BaseApplicationException { private SeverityOptions _severity = SeverityOptions.High; private string _debugMessage = string.Empty; - private object[] _messageParameters = null; + private object[]? _messageParameters = null; + /// + /// Initializes a new instance of with default values. + /// public GeneralException():base() { } + /// + /// Initializes a new instance of with the specified message. + /// + /// The exception message or format string key. public GeneralException(string keyMessage) : base(keyMessage) { _debugMessage = keyMessage; } + /// + /// Initializes a new instance of with the specified severity and message. + /// + /// The level of the exception. + /// The exception message or format string key. public GeneralException(SeverityOptions severity, string keyMessage) : base(keyMessage) { @@ -38,6 +61,11 @@ public GeneralException(SeverityOptions severity, string keyMessage) _debugMessage = keyMessage; } + /// + /// Initializes a new instance of with a parameterized message. + /// + /// The message format string. + /// Parameters to format into the message string. public GeneralException(string keyMessage, params object[] messageParameters) : base(keyMessage) { @@ -45,6 +73,12 @@ public GeneralException(string keyMessage, params object[] messageParameters) _messageParameters = messageParameters; } + /// + /// Initializes a new instance of with severity and a parameterized message. + /// + /// The level of the exception. + /// The message format string. + /// Parameters to format into the message string. public GeneralException(SeverityOptions severity, string keyMessage, params object[] messageParameters) : base(keyMessage) { @@ -53,12 +87,23 @@ public GeneralException(SeverityOptions severity, string keyMessage, params obje _messageParameters = messageParameters; } + /// + /// Initializes a new instance of with a message and inner exception. + /// + /// The exception message or format string key. + /// The inner exception that caused this exception. public GeneralException(string keyMessage, System.Exception innerException) : base(keyMessage, innerException) { _debugMessage = keyMessage; } + /// + /// Initializes a new instance of with a parameterized message and inner exception. + /// + /// The message format string. + /// The inner exception that caused this exception. + /// Parameters to format into the message string. public GeneralException(string keyMessage, System.Exception innerException, params object[] messageParameters) : base(keyMessage, innerException) { @@ -66,6 +111,9 @@ public GeneralException(string keyMessage, System.Exception innerException, para _messageParameters = messageParameters; } + /// + /// Gets the formatted exception message, applying any message parameters if provided. + /// public override string Message { get @@ -74,6 +122,10 @@ public override string Message } } + /// + /// Gets the debug-friendly message with parameters applied via . + /// If no parameters were provided, returns the base message unmodified. + /// public string DebugMessage { get @@ -82,6 +134,10 @@ public string DebugMessage } } + /// + /// Gets or sets the severity level of this exception. + /// + /// public SeverityOptions Severity { get @@ -94,6 +150,10 @@ public SeverityOptions Severity } } + /// + /// Formats the exception message by delegating to . + /// + /// The formatted message string. private string FormatMessage() { return DebugMessage; diff --git a/Src/RCommon.Core/Guard.cs b/Src/RCommon.Core/Guard.cs index 7ee63c34..8e30f36a 100644 --- a/Src/RCommon.Core/Guard.cs +++ b/Src/RCommon.Core/Guard.cs @@ -10,6 +10,9 @@ namespace RCommon public class Guard { + /// + /// Initializes a new instance of the class. + /// public Guard() { @@ -26,21 +29,21 @@ public Guard() public static void Against(bool assertion, string message) where TException : Exception { if (assertion) - throw (TException)Activator.CreateInstance(typeof(TException), message); + throw (TException)Activator.CreateInstance(typeof(TException), message)!; } /// /// Throws an exception of type with the specified message - /// when the assertion + /// when the assertion function evaluates to true. /// - /// - /// - /// + /// The type of exception to throw. + /// A function that returns the assertion to evaluate. If true then the exception is thrown. + /// string. The exception message to throw. public static void Against(Func assertion, string message) where TException : Exception { //Execute the lambda and if it evaluates to true then throw the exception. if (assertion()) - throw (TException)Activator.CreateInstance(typeof(TException), message); + throw (TException)Activator.CreateInstance(typeof(TException), message)!; } /// @@ -116,15 +119,27 @@ public static void TypeOf(object instance, string message) public static void IsEqual(object compare, object instance, string message) where TException : Exception { if (compare != instance) - throw (TException)Activator.CreateInstance(typeof(TException), message); + throw (TException)Activator.CreateInstance(typeof(TException), message)!; } + /// + /// Throws an when the specified argument is . + /// + /// The value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotEmpty(Guid argument, string argumentName) { IsNotEmpty(argument, argumentName, true); } + /// + /// Checks whether the specified argument is not . + /// + /// The value to check. + /// The name of the argument for the exception message. + /// If true, throws an ; otherwise returns false. + /// true if the argument is not empty; false if empty and is false. [DebuggerStepThrough] public static bool IsNotEmpty(Guid argument, string argumentName, bool throwException) { @@ -142,12 +157,24 @@ public static bool IsNotEmpty(Guid argument, string argumentName, bool throwExce } } + /// + /// Throws an when the specified string argument is null, empty, or whitespace. + /// + /// The string value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotEmpty(string argument, string argumentName) { IsNotEmpty(argument, argumentName, true); } + /// + /// Checks whether the specified string argument is not null, empty, or whitespace. + /// + /// The string value to check. + /// The name of the argument for the exception message. + /// If true, throws an ; otherwise returns false. + /// true if the argument is not blank; false if blank and is false. [DebuggerStepThrough] public static bool IsNotEmpty(string argument, string argumentName, bool throwException) { @@ -165,6 +192,12 @@ public static bool IsNotEmpty(string argument, string argumentName, bool throwEx } } + /// + /// Throws an when the trimmed string exceeds the specified maximum length. + /// + /// The string value to check. + /// The maximum allowed length. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotOutOfLength(string argument, int length, string argumentName) { @@ -174,6 +207,11 @@ public static void IsNotOutOfLength(string argument, int length, string argument } } + /// + /// Throws an when the specified argument is null. + /// + /// The object to check for null. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNull(object argument, string argumentName) { @@ -183,6 +221,11 @@ public static void IsNotNull(object argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative. + /// + /// The integer value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegative(int argument, string argumentName) { @@ -192,12 +235,24 @@ public static void IsNotNegative(int argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative or zero. + /// + /// The integer value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegativeOrZero(int argument, string argumentName) { IsNotNegativeOrZero(argument, argumentName, true); } + /// + /// Checks whether the specified argument is not negative or zero. + /// + /// The integer value to check. + /// The name of the argument for the exception message. + /// If true, throws an ; otherwise returns false. + /// true if the argument is positive; false if non-positive and is false. public static bool IsNotNegativeOrZero(int argument, string argumentName, bool throwException) { if (argument <= 0) @@ -214,6 +269,11 @@ public static bool IsNotNegativeOrZero(int argument, string argumentName, bool t } } + /// + /// Throws an when the specified argument is negative. + /// + /// The long value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegative(long argument, string argumentName) { @@ -223,6 +283,11 @@ public static void IsNotNegative(long argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative or zero. + /// + /// The long value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegativeOrZero(long argument, string argumentName) { @@ -232,6 +297,11 @@ public static void IsNotNegativeOrZero(long argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative. + /// + /// The float value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegative(float argument, string argumentName) { @@ -241,6 +311,11 @@ public static void IsNotNegative(float argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative or zero. + /// + /// The float value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegativeOrZero(float argument, string argumentName) { @@ -250,6 +325,11 @@ public static void IsNotNegativeOrZero(float argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative. + /// + /// The decimal value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegative(decimal argument, string argumentName) { @@ -259,6 +339,11 @@ public static void IsNotNegative(decimal argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative or zero. + /// + /// The decimal value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegativeOrZero(decimal argument, string argumentName) { @@ -268,6 +353,11 @@ public static void IsNotNegativeOrZero(decimal argument, string argumentName) } } + /// + /// Throws an when the specified argument is not a valid date. + /// + /// The value to validate. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotInvalidDate(DateTime argument, string argumentName) { @@ -277,6 +367,11 @@ public static void IsNotInvalidDate(DateTime argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative. + /// + /// The value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegative(TimeSpan argument, string argumentName) { @@ -286,6 +381,11 @@ public static void IsNotNegative(TimeSpan argument, string argumentName) } } + /// + /// Throws an when the specified argument is negative or zero. + /// + /// The value to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotNegativeOrZero(TimeSpan argument, string argumentName) { @@ -295,6 +395,12 @@ public static void IsNotNegativeOrZero(TimeSpan argument, string argumentName) } } + /// + /// Throws an when the specified collection is null or contains no elements. + /// + /// The element type of the collection. + /// The collection to check. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotEmpty(ICollection argument, string argumentName) { @@ -306,6 +412,14 @@ public static void IsNotEmpty(ICollection argument, string argumentName) } } + /// + /// Throws an when the specified argument + /// falls outside the inclusive range of to . + /// + /// The integer value to check. + /// The minimum allowed value (inclusive). + /// The maximum allowed value (inclusive). + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotOutOfRange(int argument, int min, int max, string argumentName) { @@ -315,6 +429,11 @@ public static void IsNotOutOfRange(int argument, int min, int max, string argume } } + /// + /// Throws an when the specified string is not a valid email address. + /// + /// The string to validate as an email address. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotInvalidEmail(string argument, string argumentName) { @@ -326,6 +445,11 @@ public static void IsNotInvalidEmail(string argument, string argumentName) } } + /// + /// Throws an when the specified string is not a valid web URL. + /// + /// The string to validate as a web URL. + /// The name of the argument for the exception message. [DebuggerStepThrough] public static void IsNotInvalidWebUrl(string argument, string argumentName) { diff --git a/Src/RCommon.Core/ICommonFactory.cs b/Src/RCommon.Core/ICommonFactory.cs index 122bebc7..f53d2be1 100644 --- a/Src/RCommon.Core/ICommonFactory.cs +++ b/Src/RCommon.Core/ICommonFactory.cs @@ -4,21 +4,79 @@ namespace RCommon { + /// + /// Defines a factory that creates instances of with optional customization. + /// + /// The type of object to create. + /// public interface ICommonFactory { + /// + /// Creates a new instance of . + /// + /// A new instance of . T Create(); + + /// + /// Creates a new instance of and applies the specified customization action. + /// + /// An action to customize the created instance before it is returned. + /// A customized instance of . T Create(Action customize); } + /// + /// Defines a factory that creates instances of using a single argument, + /// with optional customization. + /// + /// The type of the input argument. + /// The type of object to create. + /// public interface ICommonFactory { + /// + /// Creates a new instance of using the specified argument. + /// + /// The input argument used during creation. + /// A new instance of . TResult Create(T arg); + + /// + /// Creates a new instance of using the specified argument + /// and applies the customization action. + /// + /// The input argument used during creation. + /// An action to customize the created instance before it is returned. + /// A customized instance of . TResult Create(T arg, Action customize); } + /// + /// Defines a factory that creates instances of using two arguments, + /// with optional customization. + /// + /// The type of the first input argument. + /// The type of the second input argument. + /// The type of object to create. + /// public interface ICommonFactory { + /// + /// Creates a new instance of using the specified arguments. + /// + /// The first input argument. + /// The second input argument. + /// A new instance of . TResult Create(T arg, T2 arg2); + + /// + /// Creates a new instance of using the specified arguments + /// and applies the customization action. + /// + /// The first input argument. + /// The second input argument. + /// An action to customize the created instance before it is returned. + /// A customized instance of . TResult Create(T arg, T2 arg2, Action customize); } diff --git a/Src/RCommon.Core/IPagedSpecification.cs b/Src/RCommon.Core/IPagedSpecification.cs index 23364dc9..6b806234 100644 --- a/Src/RCommon.Core/IPagedSpecification.cs +++ b/Src/RCommon.Core/IPagedSpecification.cs @@ -7,13 +7,32 @@ namespace RCommon { + /// + /// Extends to add paging and ordering support for query specifications. + /// + /// The entity type that the specification applies to. + /// public interface IPagedSpecification : ISpecification { + /// + /// Gets the 1-based page number to retrieve. + /// public int PageNumber { get; } + + /// + /// Gets the number of items per page. + /// public int PageSize { get; } + /// + /// Gets the expression used to determine the sort order of results. + /// public Expression> OrderByExpression { get; } + /// + /// Gets or sets a value indicating whether results should be sorted in ascending order. + /// When false, results are sorted in descending order. + /// public bool OrderByAscending { get; set; } } } diff --git a/Src/RCommon.Core/IRCommonBuilder.cs b/Src/RCommon.Core/IRCommonBuilder.cs index d5f19710..99c13a09 100644 --- a/Src/RCommon.Core/IRCommonBuilder.cs +++ b/Src/RCommon.Core/IRCommonBuilder.cs @@ -3,14 +3,53 @@ namespace RCommon { + /// + /// Defines the fluent builder interface for configuring RCommon framework services including + /// GUID generation, date/time systems, and common factories. + /// + /// public interface IRCommonBuilder { + /// + /// Gets the used to register services. + /// IServiceCollection Services { get; } + /// + /// Finalizes the configuration and returns the populated . + /// + /// The configured . IServiceCollection Configure(); + + /// + /// Configures the date/time system using the specified . + /// + /// An action to configure . + /// The builder instance for method chaining. IRCommonBuilder WithDateTimeSystem(Action actions); + + /// + /// Configures the to use + /// with the specified options. + /// + /// An action to configure . + /// The builder instance for method chaining. IRCommonBuilder WithSequentialGuidGenerator(Action actions); + + /// + /// Configures the to use + /// which generates standard random GUIDs. + /// + /// The builder instance for method chaining. IRCommonBuilder WithSimpleGuidGenerator(); + + /// + /// Registers a service and its implementation along with a corresponding + /// for creating instances through dependency injection. + /// + /// The service interface type. + /// The concrete implementation type. + /// The builder instance for method chaining. IRCommonBuilder WithCommonFactory() where TService : class where TImplementation : class, TService; diff --git a/Src/RCommon.Core/ISystemTime.cs b/Src/RCommon.Core/ISystemTime.cs index ddc5bff2..bfa66c2b 100644 --- a/Src/RCommon.Core/ISystemTime.cs +++ b/Src/RCommon.Core/ISystemTime.cs @@ -4,6 +4,11 @@ namespace RCommon { + /// + /// Abstracts the system clock to enable testable time-dependent code and consistent time zone handling. + /// + /// + /// public interface ISystemTime { /// diff --git a/Src/RCommon.Core/ISystemTimeOptions.cs b/Src/RCommon.Core/ISystemTimeOptions.cs index 3c314b62..52085cd4 100644 --- a/Src/RCommon.Core/ISystemTimeOptions.cs +++ b/Src/RCommon.Core/ISystemTimeOptions.cs @@ -4,8 +4,16 @@ namespace RCommon { + /// + /// Defines configuration options for the abstraction. + /// + /// public interface ISystemTimeOptions { + /// + /// Gets or sets the that controls whether the system time + /// operates in UTC, Local, or Unspecified mode. + /// DateTimeKind Kind { get; set; } } } diff --git a/Src/RCommon.Core/InvalidArgumentException.cs b/Src/RCommon.Core/InvalidArgumentException.cs index c2bc28c0..9fe448c7 100644 --- a/Src/RCommon.Core/InvalidArgumentException.cs +++ b/Src/RCommon.Core/InvalidArgumentException.cs @@ -13,23 +13,40 @@ namespace RCommon [Serializable] public class InvalidArgumentException : GeneralException { + /// + /// Initializes a new instance of with severity. + /// public InvalidArgumentException() { base.Severity = SeverityOptions.Low; } + /// + /// Initializes a new instance of with the specified message. + /// + /// The exception message or format string key. public InvalidArgumentException(string keyMessage) : base(keyMessage) { base.Severity = SeverityOptions.Low; } + /// + /// Initializes a new instance of with a parameterized message. + /// + /// The message format string. + /// Parameters to format into the message string. public InvalidArgumentException(string keyMessage, params object[] messageParameters) : base(keyMessage, messageParameters) { base.Severity = SeverityOptions.Low; } + /// + /// Initializes a new instance of with a message and inner exception. + /// + /// The exception message or format string key. + /// The inner exception that caused this exception. public InvalidArgumentException(string keyMessage, System.Exception innerException) : base(keyMessage, innerException) { diff --git a/Src/RCommon.Core/Linq/Linq.cs b/Src/RCommon.Core/Linq/Linq.cs index cd70ff67..4d52a01e 100644 --- a/Src/RCommon.Core/Linq/Linq.cs +++ b/Src/RCommon.Core/Linq/Linq.cs @@ -12,13 +12,27 @@ namespace RCommon.Linq /// public static class Linq { - // Returns the given anonymous method as a lambda expression + /// + /// Returns the given anonymous method as a strongly-typed lambda expression. + /// Useful for building dynamic LINQ queries where the compiler cannot infer the expression type. + /// + /// The input type of the expression. + /// The return type of the expression. + /// The lambda expression to return. + /// The same expression, strongly typed. public static Expression> Expr (Expression> expr) { return expr; } - // Returns the given anonymous function as a Func delegate + /// + /// Returns the given anonymous function as a strongly-typed delegate. + /// Useful when the compiler cannot infer the delegate type from context. + /// + /// The input type of the function. + /// The return type of the function. + /// The function delegate to return. + /// The same function, strongly typed. public static Func Func (Func expr) { return expr; diff --git a/Src/RCommon.Core/Linq/PredicateBuilder.cs b/Src/RCommon.Core/Linq/PredicateBuilder.cs index b50af2c2..0c5b3b5d 100644 --- a/Src/RCommon.Core/Linq/PredicateBuilder.cs +++ b/Src/RCommon.Core/Linq/PredicateBuilder.cs @@ -8,22 +8,59 @@ namespace RCommon.Linq /// /// See http://www.albahari.com/expressions for information and examples. /// + /// + /// Provides methods to dynamically compose LINQ predicate expressions using logical AND/OR operators. + /// Start with or as an identity, then chain with + /// or . + /// + /// + /// See http://www.albahari.com/expressions for information and examples. + /// public static class PredicateBuilder { + /// + /// Creates a predicate expression that always evaluates to true. + /// Use as the starting point when building AND-chained predicates. + /// + /// The type the predicate operates on. + /// An expression that always returns true. public static Expression> True () { return f => true; } + + /// + /// Creates a predicate expression that always evaluates to false. + /// Use as the starting point when building OR-chained predicates. + /// + /// The type the predicate operates on. + /// An expression that always returns false. public static Expression> False () { return f => false; } + /// + /// Combines two predicate expressions using a logical OR (short-circuit) operation. + /// + /// The type the predicates operate on. + /// The first predicate expression. + /// The second predicate expression to OR with the first. + /// A combined predicate expression representing OR . public static Expression> Or (this Expression> expr1, Expression> expr2) { + // Invoke expr2 using expr1's parameters so both share the same parameter expression var invokedExpr = Expression.Invoke (expr2, expr1.Parameters.Cast ()); return Expression.Lambda> (Expression.OrElse (expr1.Body, invokedExpr), expr1.Parameters); } + /// + /// Combines two predicate expressions using a logical AND (short-circuit) operation. + /// + /// The type the predicates operate on. + /// The first predicate expression. + /// The second predicate expression to AND with the first. + /// A combined predicate expression representing AND . public static Expression> And (this Expression> expr1, Expression> expr2) { + // Invoke expr2 using expr1's parameters so both share the same parameter expression var invokedExpr = Expression.Invoke (expr2, expr1.Parameters.Cast ()); return Expression.Lambda> (Expression.AndAlso (expr1.Body, invokedExpr), expr1.Parameters); diff --git a/Src/RCommon.Core/PagedSpecification.cs b/Src/RCommon.Core/PagedSpecification.cs index 1d1a317c..4153ef85 100644 --- a/Src/RCommon.Core/PagedSpecification.cs +++ b/Src/RCommon.Core/PagedSpecification.cs @@ -7,9 +7,22 @@ namespace RCommon { + /// + /// Default implementation of that combines a filter predicate + /// with paging and ordering parameters for querying collections of . + /// + /// The entity type that the specification applies to. public class PagedSpecification : Specification, IPagedSpecification { - public PagedSpecification(Expression> predicate, Expression> orderByExpression, + /// + /// Initializes a new instance of with the specified filter, ordering, and paging parameters. + /// + /// The filter expression to select matching entities. + /// The expression used to determine sort order. + /// true for ascending order; false for descending. + /// The 1-based page number to retrieve. + /// The number of items per page. + public PagedSpecification(Expression> predicate, Expression> orderByExpression, bool orderByAscending, int pageNumber, int pageSize) : base(predicate) { OrderByExpression = orderByExpression; @@ -18,10 +31,16 @@ public PagedSpecification(Expression> predicate, Expression public Expression> OrderByExpression { get; } + + /// public int PageNumber { get; } + + /// public int PageSize { get; } + /// public bool OrderByAscending { get; set; } } } diff --git a/Src/RCommon.Core/RCommon.Core.csproj b/Src/RCommon.Core/RCommon.Core.csproj index 0b501504..41daed51 100644 --- a/Src/RCommon.Core/RCommon.Core.csproj +++ b/Src/RCommon.Core/RCommon.Core.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable $(MSBuildProjectName.Replace(" ", "_").Replace(".Core", "")) True RCommon.Core diff --git a/Src/RCommon.Core/RCommonBuilder.cs b/Src/RCommon.Core/RCommonBuilder.cs index 46057d59..e8f85e18 100644 --- a/Src/RCommon.Core/RCommonBuilder.cs +++ b/Src/RCommon.Core/RCommonBuilder.cs @@ -12,29 +12,39 @@ namespace RCommon /// public class RCommonBuilder : IRCommonBuilder { + /// public IServiceCollection Services { get; } private bool _guidConfigured = false; private bool _dateTimeConfigured = false; + /// + /// Initializes a new instance of and registers core framework services + /// including caching options, the , the , + /// and the default . + /// + /// The to register services into. + /// Thrown when is null. public RCommonBuilder(IServiceCollection services) { Guard.Against(services == null, "IServiceCollection cannot be null"); - Services = services; + Services = services!; this.Services.Configure(x => { x.CachingEnabled = false; }); // Event Subscription Manager - tracks which events are routed to which producers - services.AddSingleton(new EventSubscriptionManager()); + Services.AddSingleton(new EventSubscriptionManager()); // Event Bus - services.AddSingleton(sp => + Services.AddSingleton(sp => { - return new InMemoryEventBus(sp, services); + return new InMemoryEventBus(sp, Services); }); Services.AddScoped(); } + /// + /// Thrown if a GUID generator has already been configured. public IRCommonBuilder WithSequentialGuidGenerator(Action actions) { Guard.Against(this._guidConfigured, @@ -45,6 +55,8 @@ public IRCommonBuilder WithSequentialGuidGenerator(Action + /// Thrown if a GUID generator has already been configured. public IRCommonBuilder WithSimpleGuidGenerator() { Guard.Against(this._guidConfigured, @@ -54,6 +66,8 @@ public IRCommonBuilder WithSimpleGuidGenerator() return this; } + /// + /// Thrown if the date/time system has already been configured. public IRCommonBuilder WithDateTimeSystem(Action actions) { Guard.Against(this._dateTimeConfigured, @@ -64,16 +78,18 @@ public IRCommonBuilder WithDateTimeSystem(Action actions) return this; } + /// public IRCommonBuilder WithCommonFactory() where TService : class where TImplementation : class, TService { this.Services.AddTransient(); - this.Services.AddScoped>(x => () => x.GetService()); + this.Services.AddScoped>(x => () => x.GetService()!); this.Services.AddScoped, CommonFactory>(); return this; } + /// public virtual IServiceCollection Configure() { return this.Services; diff --git a/Src/RCommon.Core/RCommonBuilderException.cs b/Src/RCommon.Core/RCommonBuilderException.cs index c597ecc1..2c6a514a 100644 --- a/Src/RCommon.Core/RCommonBuilderException.cs +++ b/Src/RCommon.Core/RCommonBuilderException.cs @@ -6,8 +6,18 @@ namespace RCommon { + /// + /// Exception thrown when the encounters a configuration error, + /// such as attempting to configure a service that has already been configured. + /// Always has severity. + /// public class RCommonBuilderException : GeneralException { + /// + /// Initializes a new instance of with the specified message + /// and severity. + /// + /// The error message describing the configuration failure. public RCommonBuilderException(string message) : base(SeverityOptions.Critical , message) { diff --git a/Src/RCommon.Core/README.md b/Src/RCommon.Core/README.md index e469bd29..ac5ab3a5 100644 --- a/Src/RCommon.Core/README.md +++ b/Src/RCommon.Core/README.md @@ -1,3 +1,73 @@ # RCommon.Core -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +The foundation package for the RCommon framework, providing the fluent builder for dependency injection configuration, an in-memory event bus with transactional event routing, guard clauses, GUID generation, system time abstraction, and a rich set of extension methods. + +## Features + +- Fluent `AddRCommon()` builder pattern for configuring framework services via Microsoft DI +- In-memory event bus (`IEventBus`) with publish/subscribe support and polymorphic event dispatch +- Transactional event routing (`IEventRouter`) that stores events and dispatches them to the correct `IEventProducer` instances based on subscription configuration +- `EventSubscriptionManager` for isolating event subscriptions so each producer only receives its registered events +- `Guard` class with validation methods for nulls, empty strings, ranges, types, collections, email, and more +- Sequential and simple GUID generators (`IGuidGenerator`) optimized for database-friendly ordering +- `ISystemTime` abstraction for testable, time zone-aware date/time handling +- `ICommonFactory` for DI-aware factory pattern with customization support +- Extension methods for collections, strings, expressions, streams, dictionaries, reflection, and IQueryable +- Reflection utilities including `ObjectGraphWalker` for traversing object graphs and `ReflectionHelper` for generic type inspection and compiled method invocation + +## Installation + +```shell +dotnet add package RCommon.Core +``` + +## Usage + +```csharp +using RCommon; +using RCommon.EventHandling; + +// Bootstrap RCommon in your DI configuration +services.AddRCommon(builder => +{ + builder + .WithSequentialGuidGenerator(options => + options.DefaultSequentialGuidType = SequentialGuidType.SequentialAsString) + .WithDateTimeSystem(options => + options.Kind = DateTimeKind.Utc) + .WithEventHandling(eventHandling => + { + eventHandling.AddSubscriber(); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IRCommonBuilder` | Fluent builder interface for configuring RCommon framework services | +| `IEventBus` | In-process event bus for publishing events and subscribing handlers | +| `ISubscriber` | Strongly-typed event subscriber that handles a specific event type | +| `IEventRouter` | Routes stored transactional events to the appropriate `IEventProducer` instances | +| `IEventProducer` | Dispatches serializable events to a destination (bus, broker, etc.) | +| `EventSubscriptionManager` | Tracks event-to-producer subscriptions for isolated event routing | +| `Guard` | Utility class with guard clause methods for parameter validation | +| `IGuidGenerator` | Abstraction for GUID generation (sequential or simple) | +| `ISystemTime` | Abstracts the system clock for testable time-dependent code | +| `ICommonFactory` | DI-aware factory pattern for creating service instances | +| `ObjectGraphWalker` | Recursively traverses an object graph searching for instances of a specified type | +| `ReflectionHelper` | Utilities for generic type inspection, attribute retrieval, and compiled method invocation | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Models](https://www.nuget.org/packages/RCommon.Models) - Shared models for CQRS commands, queries, events, pagination, and execution results +- [RCommon.Entities](https://www.nuget.org/packages/RCommon.Entities) - Domain entity base classes with auditing, soft delete, and transactional event tracking + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs b/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs index c35c3dba..d279f235 100644 --- a/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs +++ b/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs @@ -8,8 +8,22 @@ namespace RCommon.Reflection { + /// + /// Recursively traverses an object graph, searching for instances of a specified type + /// across all public properties, fields, and enumerable collections. + /// + /// + /// The walker tracks visited objects to avoid infinite recursion from circular references. + /// public static class ObjectGraphWalker { + /// + /// Traverses an object graph starting from and collects all instances + /// of type found within the graph. + /// + /// The type to search for in the object graph. Must be a reference type. + /// The root object to begin traversal from. + /// An enumerable of all instances found in the graph. public static IEnumerable TraverseGraphFor(object root) where T : class { var results = new List(); @@ -18,7 +32,16 @@ public static IEnumerable TraverseGraphFor(object root) where T : class return results.ToArray(); } - private static void Walk(object source, IList results, IList visited) + /// + /// Recursively walks an object: checks if it matches , + /// enumerates it if it is a sequence, and inspects its public properties and fields. + /// Tracks visited objects to prevent infinite loops from circular references. + /// + /// The type to search for. + /// The current object being inspected. + /// The accumulator list for matching instances. + /// The list of already-visited objects to prevent cycles. + private static void Walk(object? source, IList results, IList visited) where T : class { if (source == null) return; @@ -31,18 +54,24 @@ private static void Walk(object source, IList results, IList visited) results.Add((T)source); } - // source is a sequence of objects + // source is a sequence of objects (includes Array, IDictionary, IList, IQueryable) if (source is IEnumerable) { - // includes Array, IDictionary, IList, IQueryable WalkSequence((IEnumerable)source, results, visited); } - // dive into the object's properties + // dive into the object's properties and fields WalkComplexObject(source, results, visited); } - private static void WalkSequence(IEnumerable source, + /// + /// Iterates over each element in a sequence and recursively walks it. + /// + /// The type to search for. + /// The enumerable sequence to iterate. + /// The accumulator list for matching instances. + /// The list of already-visited objects to prevent cycles. + private static void WalkSequence(IEnumerable? source, IList results, IList visited) where T : class { @@ -53,12 +82,21 @@ private static void WalkSequence(IEnumerable source, } } - private static void WalkComplexObject(object source, + /// + /// Inspects all public instance fields and readable non-indexed properties of the source object, + /// recursively walking each value to find instances of . + /// + /// The type to search for. + /// The complex object whose members are inspected. + /// The accumulator list for matching instances. + /// The list of already-visited objects to prevent cycles. + private static void WalkComplexObject(object? source, IList results, IList visited) where T : class { if (source == null) return; var type = source.GetType(); + // Only inspect readable, non-indexed properties to avoid exceptions var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(x => x.CanRead && !x.GetIndexParameters().Any()); var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); diff --git a/Src/RCommon.Core/Reflection/ReflectionHelper.cs b/Src/RCommon.Core/Reflection/ReflectionHelper.cs index 0821ee50..e24c0295 100644 --- a/Src/RCommon.Core/Reflection/ReflectionHelper.cs +++ b/Src/RCommon.Core/Reflection/ReflectionHelper.cs @@ -6,7 +6,10 @@ namespace RCommon.Reflection { - //TODO: Consider to make internal + /// + /// Provides utility methods for reflection-based operations including generic type inspection, + /// attribute retrieval, property path navigation, constant discovery, and compiled method invocation. + /// public static class ReflectionHelper { //TODO: Ehhance summary @@ -40,7 +43,13 @@ public static bool IsAssignableToGenericType(Type givenType, Type genericType) return IsAssignableToGenericType(givenTypeInfo.BaseType, genericType); } - //TODO: Summary + /// + /// Gets all closed generic types that implements or inherits + /// matching the open generic type definition specified by . + /// + /// The type to inspect. + /// The open generic type definition to match against (e.g., typeof(IRepository<>)). + /// A list of closed generic types matching the specified definition. public static List GetImplementedGenericTypes(Type givenType, Type genericType) { var result = new List(); @@ -48,6 +57,14 @@ public static List GetImplementedGenericTypes(Type givenType, Type generic return result; } + /// + /// Recursively searches through a type's hierarchy (interfaces and base types) + /// to find all closed generic types matching the given open generic type definition, + /// adding each unique match to the result list. + /// + /// The accumulator list for matching types. + /// The current type being inspected. + /// The open generic type definition to match against. private static void AddImplementedGenericTypes(List result, Type givenType, Type genericType) { var givenTypeInfo = givenType.GetTypeInfo(); @@ -81,7 +98,7 @@ private static void AddImplementedGenericTypes(List result, Type givenType /// MemberInfo /// Default value (null as default) /// Inherit attribute from base classes - public static TAttribute GetSingleAttributeOrDefault(MemberInfo memberInfo, TAttribute defaultValue = default, bool inherit = true) + public static TAttribute? GetSingleAttributeOrDefault(MemberInfo memberInfo, TAttribute? defaultValue = default, bool inherit = true) where TAttribute : Attribute { //Get attribute on the member @@ -101,7 +118,7 @@ public static TAttribute GetSingleAttributeOrDefault(MemberInfo memb /// MemberInfo /// Default value (null as default) /// Inherit attribute from base classes - public static TAttribute GetSingleAttributeOfMemberOrDeclaringTypeOrDefault(MemberInfo memberInfo, TAttribute defaultValue = default, bool inherit = true) + public static TAttribute? GetSingleAttributeOfMemberOrDeclaringTypeOrDefault(MemberInfo memberInfo, TAttribute? defaultValue = default, bool inherit = true) where TAttribute : class { return memberInfo.GetCustomAttributes(true).OfType().FirstOrDefault() @@ -129,9 +146,9 @@ public static IEnumerable GetAttributesOfMemberOrDeclaringType /// Gets value of a property by it's full path from given object /// - public static object GetValueByPath(object obj, Type objectType, string propertyPath) + public static object? GetValueByPath(object obj, Type objectType, string propertyPath) { - var value = obj; + object? value = obj; var currentType = objectType; var objectPath = currentType.FullName; var absolutePropertyPath = propertyPath; @@ -167,10 +184,10 @@ public static object GetValueByPath(object obj, Type objectType, string property internal static void SetValueByPath(object obj, Type objectType, string propertyPath, object value) { var currentType = objectType; - PropertyInfo property; + PropertyInfo? property; var objectPath = currentType.FullName; var absolutePropertyPath = propertyPath; - if (absolutePropertyPath.StartsWith(objectPath)) + if (objectPath != null && absolutePropertyPath.StartsWith(objectPath)) { absolutePropertyPath = absolutePropertyPath.Replace(objectPath + ".", ""); } @@ -180,19 +197,20 @@ internal static void SetValueByPath(object obj, Type objectType, string property if (properties.Length == 1) { property = objectType.GetProperty(properties.First()); - property.SetValue(obj, value); + property?.SetValue(obj, value); return; } for (int i = 0; i < properties.Length - 1; i++) { property = currentType.GetProperty(properties[i]); - obj = property.GetValue(obj, null); + if (property == null) return; + obj = property.GetValue(obj, null)!; currentType = property.PropertyType; } property = currentType.GetProperty(properties.Last()); - property.SetValue(obj, value); + property?.SetValue(obj, value); } @@ -216,7 +234,7 @@ void Recursively(List constants, Type targetType, int currentDepth) constants.AddRange(targetType.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) .Where(x => x.IsLiteral && !x.IsInitOnly) - .Select(x => x.GetValue(null).ToString())); + .Select(x => x.GetValue(null)?.ToString() ?? string.Empty)); var nestedTypes = targetType.GetNestedTypes(BindingFlags.Public); @@ -285,7 +303,7 @@ public static TResult CompileMethodInvocation(MethodInfo methodInfo) instanceArgument, }; - var type = methodInfo.DeclaringType; + var type = methodInfo.DeclaringType!; var instanceVariable = Expression.Variable(type); var blockVariables = new List { diff --git a/Src/RCommon.Core/SequentialGuidGenerator.cs b/Src/RCommon.Core/SequentialGuidGenerator.cs index d4e6d6d6..03d9ea3a 100644 --- a/Src/RCommon.Core/SequentialGuidGenerator.cs +++ b/Src/RCommon.Core/SequentialGuidGenerator.cs @@ -14,20 +14,42 @@ namespace RCommon /// public class SequentialGuidGenerator : IGuidGenerator { + /// + /// Gets the options that configure the default for GUID generation. + /// public SequentialGuidGeneratorOptions Options { get; } private static readonly RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create(); + /// + /// Initializes a new instance of with the specified options. + /// + /// The options controlling the default sequential GUID type. public SequentialGuidGenerator(IOptions options) { Options = options.Value; } + /// + /// Creates a new sequential using the default + /// from . + /// + /// A new sequential . public Guid Create() { return Create(Options.GetDefaultSequentialGuidType()); } + /// + /// Creates a new sequential of the specified type. + /// + /// The controlling byte layout for database compatibility. + /// A new sequential with a timestamp-based sequential portion. + /// + /// The GUID is composed of 10 bytes of cryptographically random data and a 6-byte timestamp + /// derived from in millisecond resolution. The byte ordering + /// varies by to optimize for different database platforms. + /// public Guid Create(SequentialGuidType guidType) { // We start with 16 bytes of cryptographically strong random data. diff --git a/Src/RCommon.Core/SequentialGuidGeneratorOptions.cs b/Src/RCommon.Core/SequentialGuidGeneratorOptions.cs index 745d91d4..c49fc9cd 100644 --- a/Src/RCommon.Core/SequentialGuidGeneratorOptions.cs +++ b/Src/RCommon.Core/SequentialGuidGeneratorOptions.cs @@ -2,6 +2,10 @@ namespace RCommon { + /// + /// Configuration options for the , controlling the default + /// used when generating sequential GUIDs. + /// public class SequentialGuidGeneratorOptions { /// diff --git a/Src/RCommon.Core/SimpleGuidGenerator.cs b/Src/RCommon.Core/SimpleGuidGenerator.cs index 21388011..85df5776 100644 --- a/Src/RCommon.Core/SimpleGuidGenerator.cs +++ b/Src/RCommon.Core/SimpleGuidGenerator.cs @@ -9,6 +9,10 @@ public class SimpleGuidGenerator : IGuidGenerator { + /// + /// Creates a new random using . + /// + /// A new randomly generated . public virtual Guid Create() { return Guid.NewGuid(); diff --git a/Src/RCommon.Core/Specification.cs b/Src/RCommon.Core/Specification.cs index 94fee7d2..0d15bd65 100644 --- a/Src/RCommon.Core/Specification.cs +++ b/Src/RCommon.Core/Specification.cs @@ -42,8 +42,8 @@ public class Specification : ISpecification public Specification(Expression> predicate) { Guard.Against(predicate == null, "Expected a non null expression as a predicate for the specification."); - _predicate = predicate; - _predicateCompiled = predicate.Compile(); + _predicate = predicate!; + _predicateCompiled = predicate!.Compile(); } /// diff --git a/Src/RCommon.Core/SystemTime.cs b/Src/RCommon.Core/SystemTime.cs index cd8491db..1777a013 100644 --- a/Src/RCommon.Core/SystemTime.cs +++ b/Src/RCommon.Core/SystemTime.cs @@ -5,38 +5,63 @@ namespace RCommon { + /// + /// Default implementation of that provides the current date/time + /// based on the configured and normalizes values accordingly. + /// public class SystemTime : ISystemTime { + /// + /// Gets the configuration options for this system time instance. + /// protected SystemTimeOptions Options { get; } + /// + /// Initializes a new instance of with the specified options. + /// + /// The options controlling the behavior. public SystemTime(IOptions options) { Options = options.Value; } + /// public virtual DateTime Now => Options.Kind == DateTimeKind.Utc ? DateTime.UtcNow : DateTime.Now; + /// public virtual DateTimeKind Kind => Options.Kind; + /// public virtual bool SupportsMultipleTimezone => Options.Kind == DateTimeKind.Utc; + /// + /// + /// Conversion rules: if is or matches the + /// input's Kind, the value is returned unchanged. Otherwise UTC-to-Local or Local-to-UTC conversion + /// is performed. If the input Kind is , the Kind is simply + /// re-specified without conversion. + /// public virtual DateTime Normalize(DateTime dateTime) { + // No conversion needed when Kind is unspecified or matches the input if (Kind == DateTimeKind.Unspecified || Kind == dateTime.Kind) { return dateTime; } + // Convert UTC input to local time if (Kind == DateTimeKind.Local && dateTime.Kind == DateTimeKind.Utc) { return dateTime.ToLocalTime(); } + // Convert local input to UTC if (Kind == DateTimeKind.Utc && dateTime.Kind == DateTimeKind.Local) { return dateTime.ToUniversalTime(); } + // For unspecified input, just re-specify the Kind without actual conversion return DateTime.SpecifyKind(dateTime, Kind); } } diff --git a/Src/RCommon.Core/SystemTimeOptions.cs b/Src/RCommon.Core/SystemTimeOptions.cs index 3e0154cb..5856dd1e 100644 --- a/Src/RCommon.Core/SystemTimeOptions.cs +++ b/Src/RCommon.Core/SystemTimeOptions.cs @@ -4,13 +4,21 @@ namespace RCommon { + /// + /// Default implementation of providing configuration + /// for the service. + /// public class SystemTimeOptions : ISystemTimeOptions { /// - /// Default: + /// Gets or sets the that controls system time behavior. + /// Default: . /// public DateTimeKind Kind { get; set; } + /// + /// Initializes a new instance of with as the default kind. + /// public SystemTimeOptions() { Kind = DateTimeKind.Unspecified; diff --git a/Src/RCommon.Core/Threading/AsyncHelper.cs b/Src/RCommon.Core/Threading/AsyncHelper.cs index c568b8a9..c8c313b0 100644 --- a/Src/RCommon.Core/Threading/AsyncHelper.cs +++ b/Src/RCommon.Core/Threading/AsyncHelper.cs @@ -7,6 +7,10 @@ namespace RCommon.Core.Threading { + /// + /// Provides helper methods to run asynchronous methods synchronously using + /// from the Nito.AsyncEx library. This avoids deadlocks that can occur with Task.Result or Task.Wait(). + /// public static class AsyncHelper { diff --git a/Src/RCommon.Core/Util/ImageHelper.cs b/Src/RCommon.Core/Util/ImageHelper.cs index b2c978a9..ba3d1960 100644 --- a/Src/RCommon.Core/Util/ImageHelper.cs +++ b/Src/RCommon.Core/Util/ImageHelper.cs @@ -5,19 +5,40 @@ namespace RCommon.Util { + /// + /// Provides utility methods for working with image files, including format detection from raw byte data. + /// public class ImageHelper { + /// + /// Represents supported image file formats. + /// public enum ImageFormat { + /// Bitmap image format. Bmp, + /// JPEG image format. Jpeg, + /// GIF image format. Gif, + /// TIFF image format. Tiff, + /// PNG image format. Png, + /// Unrecognized image format. Unknown } + /// + /// Detects the image format by inspecting the file header (magic bytes) of the byte array. + /// + /// The raw byte data of the image file. + /// The detected , or if unrecognized. + /// + /// Supports BMP, GIF, PNG, TIFF (both byte orders), and JPEG (standard and Canon variants) + /// by comparing the leading bytes against known file signatures. + /// public static ImageFormat GetImageFormat(byte[] bytes) { // see http://www.mikekunz.com/image_file_header.html diff --git a/Src/RCommon.Core/Util/Inflector.cs b/Src/RCommon.Core/Util/Inflector.cs index 6d9b7830..59e4ccbb 100644 --- a/Src/RCommon.Core/Util/Inflector.cs +++ b/Src/RCommon.Core/Util/Inflector.cs @@ -15,8 +15,11 @@ namespace RCommon.Util /// public static class Inflector { + /// Collection of pluralization rules applied in reverse order. private static readonly List Plurals = new List(); + /// Collection of singularization rules applied in reverse order. private static readonly List Singulars = new List(); + /// Collection of words that are the same in both singular and plural forms. private static readonly List Uncountables = new List(); /// @@ -83,18 +86,31 @@ static Inflector() AddUncountable("sheep"); } + /// + /// Represents a regex-based inflection rule that transforms words via pattern matching and replacement. + /// private class Rule { private readonly Regex _regex; private readonly string _replacement; + /// + /// Initializes a new inflection rule with the specified regex pattern and replacement string. + /// + /// The regex pattern to match against words. + /// The replacement string (may include regex group references like $1). public Rule(string pattern, string replacement) { _regex = new Regex(pattern, RegexOptions.IgnoreCase); _replacement = replacement; } - public string Apply(string word) + /// + /// Applies this rule to a word. Returns the transformed word if the pattern matches, or null otherwise. + /// + /// The word to transform. + /// The transformed word, or null if the pattern does not match. + public string? Apply(string word) { if (!_regex.IsMatch(word)) { @@ -105,27 +121,54 @@ public string Apply(string word) } } + /// + /// Adds bidirectional rules for an irregular word (e.g., "person" / "people"), + /// preserving the first letter's case. + /// + /// The singular form of the irregular word. + /// The plural form of the irregular word. private static void AddIrregular(string singular, string plural) { AddPlural("(" + singular[0] + ")" + singular.Substring(1) + "$", "$1" + plural.Substring(1)); AddSingular("(" + plural[0] + ")" + plural.Substring(1) + "$", "$1" + singular.Substring(1)); } + /// + /// Registers a word as uncountable (same in singular and plural forms, e.g., "sheep"). + /// + /// The uncountable word to register. private static void AddUncountable(string word) { Uncountables.Add(word.ToLower()); } + /// + /// Adds a pluralization regex rule. + /// + /// The regex pattern for matching singular words. + /// The replacement pattern for creating the plural form. private static void AddPlural(string rule, string replacement) { Plurals.Add(new Rule(rule, replacement)); } + /// + /// Adds a singularization regex rule. + /// + /// The regex pattern for matching plural words. + /// The replacement pattern for creating the singular form. private static void AddSingular(string rule, string replacement) { Singulars.Add(new Rule(rule, replacement)); } + /// + /// Applies the given list of rules to a word in reverse order (last rule wins). + /// Words in the list are returned unchanged. + /// + /// The list of rules to apply. + /// The word to transform. + /// The transformed word, or the original word if no rule matches or the word is uncountable. private static string ApplyRules(List rules, string word) { string result = word; @@ -134,8 +177,10 @@ private static string ApplyRules(List rules, string word) { for (int i = rules.Count - 1; i >= 0; i--) { - if ((result = rules[i].Apply(word)) != null) + string? applied = rules[i].Apply(word); + if (applied != null) { + result = applied; break; } } diff --git a/Src/RCommon.Dapper/Crud/DapperRepository.cs b/Src/RCommon.Dapper/Crud/DapperRepository.cs index b9ce34fa..b7479cae 100644 --- a/Src/RCommon.Dapper/Crud/DapperRepository.cs +++ b/Src/RCommon.Dapper/Crud/DapperRepository.cs @@ -22,11 +22,28 @@ namespace RCommon.Persistence.Dapper.Crud { + /// + /// A concrete repository implementation using Dapper and the Dommel extension library for CRUD operations. + /// + /// The entity type managed by this repository. Must implement . + /// + /// Each operation acquires a from the configured , + /// ensures it is open before executing, and closes it in a finally block. This repository + /// uses Dommel's extension methods (e.g., InsertAsync, DeleteAsync, SelectAsync) + /// for SQL generation from entity mappings. + /// public class DapperRepository : SqlRepositoryBase where TEntity : class, IBusinessEntity { - public DapperRepository(IDataStoreFactory dataStoreFactory, + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + public DapperRepository(IDataStoreFactory dataStoreFactory, ILoggerFactory logger, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) : base(dataStoreFactory, logger, eventTracker, defaultDataStoreOptions) @@ -34,6 +51,7 @@ public DapperRepository(IDataStoreFactory dataStoreFactory, Logger = logger.CreateLogger(GetType().Name); } + /// public override async Task AddAsync(TEntity entity, CancellationToken token = default) { @@ -66,6 +84,7 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de } + /// public override async Task DeleteAsync(TEntity entity, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -96,6 +115,7 @@ public override async Task DeleteAsync(TEntity entity, CancellationToken token = } } + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -107,7 +127,7 @@ public async override Task DeleteManyAsync(Expression> await db.OpenAsync(); } - return await db.DeleteMultipleAsync(expression, cancellationToken: token); + return await db.DeleteMultipleAsync(expression, cancellationToken: token); } catch (ApplicationException exception) { @@ -125,6 +145,7 @@ public async override Task DeleteManyAsync(Expression> } } + /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await DeleteManyAsync(specification.Predicate, token); @@ -132,6 +153,7 @@ public async override Task DeleteManyAsync(ISpecification specific + /// public override async Task UpdateAsync(TEntity entity, CancellationToken token = default) { @@ -162,11 +184,13 @@ public override async Task UpdateAsync(TEntity entity, CancellationToken token = } } + /// public override async Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await FindAsync(specification.Predicate, token); } + /// public override async Task> FindAsync(Expression> expression, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -196,6 +220,7 @@ public override async Task> FindAsync(Expression public override async Task FindAsync(object primaryKey, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -208,7 +233,7 @@ public override async Task FindAsync(object primaryKey, CancellationTok } var result = await db.GetAsync(primaryKey, cancellationToken: token); - return result; + return result!; } catch (ApplicationException exception) { @@ -225,6 +250,7 @@ public override async Task FindAsync(object primaryKey, CancellationTok } } + /// public override async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -254,6 +280,7 @@ public override async Task GetCountAsync(ISpecification selectSpe } } + /// public override async Task GetCountAsync(Expression> expression, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -284,32 +311,34 @@ public override async Task GetCountAsync(Expression> e } /// - /// Gets the single returned value based on the expression passed in. + /// Gets the single returned value based on the expression passed in. /// /// Custom Expression /// Cancellation Token /// Value matching expression criteria. - /// Do not use this if querying using primary key. Use Do not use this if querying using primary key. Use instead /// due to issues related to https://github.com/henkmollema/Dommel/issues/282 public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { + // Dommel lacks a native SingleOrDefault, so we retrieve all matches and apply SingleOrDefault in-memory var result = await FindAsync(expression, token); - return result.SingleOrDefault(); + return result.SingleOrDefault()!; } /// - /// Gets the single returned value based on the expression passed in. + /// Gets the single returned value based on the expression passed in. /// /// Custom Specification /// Cancellation Token /// Value matching specification expression criteria. - /// Do not use this if querying using primary key. Use Do not use this if querying using primary key. Use instead /// due to issues related to https://github.com/henkmollema/Dommel/issues/282 public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { return await FindSingleOrDefaultAsync(specification, token); } + /// public override async Task AnyAsync(Expression> expression, CancellationToken token = default) { await using (var db = DataStore.GetDbConnection()) @@ -339,6 +368,7 @@ public override async Task AnyAsync(Expression> expres } } + /// public override async Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await AnyAsync(specification.Predicate, token); diff --git a/Src/RCommon.Dapper/DapperFluentMappingsException.cs b/Src/RCommon.Dapper/DapperFluentMappingsException.cs index 0661541c..a35dc567 100644 --- a/Src/RCommon.Dapper/DapperFluentMappingsException.cs +++ b/Src/RCommon.Dapper/DapperFluentMappingsException.cs @@ -6,8 +6,19 @@ namespace RCommon.Persistence.Dapper { - public class DapperFluentMappingsException : GeneralException + /// + /// Exception thrown when Dapper fluent mapping configuration encounters an error. + /// + /// + /// This exception is raised with severity, + /// indicating a fatal configuration problem that prevents Dapper from operating correctly. + /// + public class DapperFluentMappingsException : GeneralException { + /// + /// Initializes a new instance of with the specified error message. + /// + /// A message describing the fluent mapping error. public DapperFluentMappingsException(string message) :base(SeverityOptions.Critical, message) { diff --git a/Src/RCommon.Dapper/DapperPersistenceBuilder.cs b/Src/RCommon.Dapper/DapperPersistenceBuilder.cs index 0315f678..1ac016ee 100644 --- a/Src/RCommon.Dapper/DapperPersistenceBuilder.cs +++ b/Src/RCommon.Dapper/DapperPersistenceBuilder.cs @@ -13,12 +13,27 @@ namespace RCommon { + /// + /// Implementation of that configures Dapper-based + /// persistence services in the dependency injection container. + /// + /// + /// Upon construction, this builder registers as the default + /// implementation for , , + /// and . + /// public class DapperPersistenceBuilder : IDapperBuilder { private readonly IServiceCollection _services; private List _dbContextTypes = new List(); + /// + /// Initializes a new instance of and registers + /// Dapper repository services in the provided service collection. + /// + /// The to register services with. + /// Thrown when is null. public DapperPersistenceBuilder(IServiceCollection services) { _services = services ?? throw new ArgumentNullException(nameof(services)); @@ -27,27 +42,43 @@ public DapperPersistenceBuilder(IServiceCollection services) services.AddTransient(typeof(ISqlMapperRepository<>), typeof(DapperRepository<>)); services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(DapperRepository<>)); services.AddTransient(typeof(IReadOnlyRepository<>), typeof(DapperRepository<>)); - + } + /// public IServiceCollection Services => _services; + /// + /// Registers a database connection type with the specified data store name and connection options. + /// + /// The type of the database connection. Must derive from . + /// A unique name identifying this data store for resolution via . + /// An action to configure the (e.g., connection string). + /// The builder instance for fluent chaining. + /// Thrown when is null or empty. + /// Thrown when is null. public IDapperBuilder AddDbConnection(string dataStoreName, Action options) where TDbConnection : RDbConnection { Guard.Against(dataStoreName.IsNullOrEmpty(), "You must set a name for the Data Store"); Guard.Against(options == null, "You must configure the options for the RDbConnection for it to be useful"); - var dbContext = typeof(TDbConnection).AssemblyQualifiedName; + // Resolve the assembly-qualified type name to register the concrete connection type + var dbContext = typeof(TDbConnection).AssemblyQualifiedName!; this._services.TryAddTransient(); - this._services.TryAddTransient(Type.GetType(dbContext)); - this._services.Configure(options => options.Register(dataStoreName)); - this._services.Configure(options); + this._services.TryAddTransient(Type.GetType(dbContext)!); + this._services.Configure(o => o.Register(dataStoreName)); + this._services.Configure(options!); return this; } + /// + /// Sets the default data store used when no explicit data store name is specified. + /// + /// An action to configure . + /// The builder instance for fluent chaining. public IPersistenceBuilder SetDefaultDataStore(Action options) { this._services.Configure(options); diff --git a/Src/RCommon.Dapper/IDapperBuilder.cs b/Src/RCommon.Dapper/IDapperBuilder.cs index d4a0161b..24ab1e2f 100644 --- a/Src/RCommon.Dapper/IDapperBuilder.cs +++ b/Src/RCommon.Dapper/IDapperBuilder.cs @@ -3,8 +3,22 @@ namespace RCommon { + /// + /// Defines the fluent builder interface for configuring Dapper-based persistence in RCommon. + /// + /// + /// Extends to add Dapper-specific configuration such as + /// registering -derived database connections with named data stores. + /// public interface IDapperBuilder : IPersistenceBuilder { + /// + /// Registers a database connection type with the specified data store name and connection options. + /// + /// The type of the database connection. Must derive from . + /// A unique name identifying this data store for resolution via . + /// An action to configure the (e.g., connection string). + /// The builder instance for fluent chaining. IDapperBuilder AddDbConnection(string dataStoreName, Action options) where TDbConnection : RDbConnection; } } diff --git a/Src/RCommon.Dapper/RCommon.Dapper.csproj b/Src/RCommon.Dapper/RCommon.Dapper.csproj index 0cf4b958..03fa255b 100644 --- a/Src/RCommon.Dapper/RCommon.Dapper.csproj +++ b/Src/RCommon.Dapper/RCommon.Dapper.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Dapper https://rcommon.com diff --git a/Src/RCommon.Dapper/README.md b/Src/RCommon.Dapper/README.md index 9bcf36b7..8a793813 100644 --- a/Src/RCommon.Dapper/README.md +++ b/Src/RCommon.Dapper/README.md @@ -1,3 +1,96 @@ - # RCommon.Dapper +# RCommon.Dapper -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Dapper implementation of the RCommon persistence abstractions. Provides a lightweight SQL mapper repository using Dapper and Dommel for CRUD operations with expression-based querying, while integrating with the RCommon data store factory and domain event tracking. + +## Features + +- `DapperRepository` implementing `ISqlMapperRepository`, `IReadOnlyRepository`, and `IWriteOnlyRepository` +- Expression-based querying via Dommel's `SelectAsync`, `CountAsync`, and `AnyAsync` extension methods +- CRUD operations mapped to SQL using Dommel's `InsertAsync`, `UpdateAsync`, and `DeleteAsync` +- Bulk delete support via `DeleteMultipleAsync` with expression predicates +- Find by primary key using Dommel's `GetAsync` +- Automatic connection lifecycle management (open/close per operation) +- Named data store support for multi-database scenarios through `IDataStoreFactory` and `RDbConnection` +- Fluent DI configuration to register database connections as named data stores +- Domain event tracking integrated into add, update, and delete operations +- Targets .NET 8, .NET 9, and .NET 10 + +## Installation + +```shell +dotnet add package RCommon.Dapper +``` + +## Usage + +```csharp +// Configure in Program.cs or Startup +builder.Services.AddRCommon() + .WithPersistence(dapper => + { + dapper.AddDbConnection("ApplicationDb", options => + { + options.DbFactory = SqlClientFactory.Instance; + options.ConnectionString = builder.Configuration.GetConnectionString("ApplicationDb"); + }); + + dapper.SetDefaultDataStore(defaults => + defaults.DefaultDataStoreName = "ApplicationDb"); + }); +``` + +Your database connection must inherit from `RDbConnection`: + +```csharp +public class ApplicationDbConnection : RDbConnection +{ + public ApplicationDbConnection(IOptions options) + : base(options) { } +} +``` + +Then inject and use the repository abstractions: + +```csharp +public class ProductService +{ + private readonly ISqlMapperRepository _productRepo; + + public ProductService(ISqlMapperRepository productRepo) + { + _productRepo = productRepo; + } + + public async Task> GetActiveProductsAsync() + { + return await _productRepo.FindAsync(p => p.IsActive); + } + + public async Task GetByIdAsync(int id) + { + return await _productRepo.FindAsync(id); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `DapperRepository` | Concrete repository using Dapper/Dommel with expression-based CRUD operations | +| `DapperPersistenceBuilder` | Fluent builder for registering Dapper database connections and repository services in DI | +| `IDapperBuilder` | Builder interface exposing `AddDbConnection()` for registering named database connections | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Persistence](https://www.nuget.org/packages/RCommon.Persistence) - Core persistence abstractions (required dependency) +- [RCommon.EFCore](https://www.nuget.org/packages/RCommon.EFCore) - Entity Framework Core implementation +- [RCommon.Linq2Db](https://www.nuget.org/packages/RCommon.Linq2Db) - Linq2Db implementation + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs index 2acebf26..db6d9715 100644 --- a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs +++ b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs @@ -22,28 +22,34 @@ namespace RCommon.Persistence.EFCore.Crud { /// - /// A concrete implementation for Entity Framework Core. - /// currently exposes much of the functionality of EF with the exception of change tracking and peristance models. We expose IQueryable to layers down stream - /// so that complex joins can be utilized and then managed at the domain level. This implementation makes special considerations for managing the lifetime of the - /// specifically when it applies to the . + /// A concrete repository implementation for Entity Framework Core that supports CRUD operations, + /// LINQ queries, eager loading, and graph-based entity navigation. /// - /// + /// The entity type managed by this repository. Must implement . + /// + /// Exposes much of the EF Core functionality with the exception of direct change tracking and persistence models. + /// Exposes to downstream layers so that complex joins can be utilized and managed at the domain level. + /// This implementation makes special considerations for managing the lifetime of the + /// specifically when it applies to the . + /// public class EFCoreRepository : GraphRepositoryBase where TEntity : class, IBusinessEntity { - private IQueryable _repositoryQuery; + private IQueryable? _repositoryQuery; private bool _tracking; - private IIncludableQueryable _includableQueryable; + private IIncludableQueryable? _includableQueryable; private readonly IDataStoreFactory _dataStoreFactory; /// - /// The default constructor for the repository. + /// Initializes a new instance of . /// - /// The is injected with scoped lifetime so it will always return the same instance of the - /// througout the HTTP request or the scope of the thread. - /// Logger used throughout the application. + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any parameter is null. public EFCoreRepository(IDataStoreFactory dataStoreFactory, ILoggerFactory logger, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) @@ -69,6 +75,9 @@ public EFCoreRepository(IDataStoreFactory dataStoreFactory, _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); } + /// + /// Gets the from the current for direct entity set operations. + /// protected DbSet ObjectSet { get @@ -77,6 +86,9 @@ protected DbSet ObjectSet } } + /// + /// Gets or sets whether EF Core change tracking is enabled for queries executed through this repository. + /// public override bool Tracking { get => _tracking; @@ -87,8 +99,14 @@ public override bool Tracking } + /// + /// Adds an eager-loading include path for the specified navigation property. + /// + /// An expression selecting the navigation property to include. + /// This repository instance for fluent chaining of additional includes. public override IEagerLoadableQueryable Include(Expression> path) { + // On first call, start from the DbSet; on subsequent calls, chain from the existing includable query if (_includableQueryable == null) { _includableQueryable = ObjectContext.Set().Include(path); @@ -97,17 +115,28 @@ public override IEagerLoadableQueryable Include(Expression + /// Adds a subsequent eager-loading path for a nested navigation property after a prior call. + /// + /// The type of the previously included navigation property. + /// The type of the nested navigation property to include. + /// An expression selecting the nested navigation property to include. + /// This repository instance for fluent chaining. public override IEagerLoadableQueryable ThenInclude(Expression> path) { - // TODO: This is likely a bug. The receiver is incorrect. - _repositoryQuery = _includableQueryable.ThenInclude(path); + // TODO: This is likely a bug. The receiver is incorrect. + _repositoryQuery = _includableQueryable!.ThenInclude(path); return this; } + /// + /// Gets the base used for all query operations. + /// Applies eager-loading expressions if any have been configured via . + /// protected override IQueryable RepositoryQuery { get @@ -117,7 +146,7 @@ protected override IQueryable RepositoryQuery _repositoryQuery = ObjectSet.AsQueryable(); } - // Start Eagerloading + // Override the base query with the eager-loaded queryable if includes have been configured if (_includableQueryable != null) { _repositoryQuery = _includableQueryable; @@ -126,6 +155,7 @@ protected override IQueryable RepositoryQuery } } + /// public override async Task AddAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); @@ -134,6 +164,7 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de } + /// public async override Task DeleteAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); @@ -141,16 +172,19 @@ public async override Task DeleteAsync(TEntity entity, CancellationToken token = await SaveAsync(); } + /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await this.DeleteManyAsync(specification.Predicate, token); } + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await this.FindQuery(expression).ExecuteDeleteAsync(token); } + /// public async override Task UpdateAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); @@ -158,13 +192,20 @@ public async override Task UpdateAsync(TEntity entity, CancellationToken token = await SaveAsync(token); } + /// + /// Core query method that applies the given filter expression to the . + /// All find operations delegate to this method to build the filtered queryable. + /// + /// A predicate expression to filter entities. + /// An representing the filtered query. + /// Thrown when is null. private IQueryable FindCore(Expression> expression) { IQueryable queryable; try { Guard.Against(RepositoryQuery == null, "RepositoryQuery is null"); - queryable = RepositoryQuery.Where(expression); + queryable = RepositoryQuery!.Where(expression); } catch (ApplicationException exception) { @@ -174,41 +215,49 @@ private IQueryable FindCore(Expression> expression) return queryable; } + /// public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { return await FindCore(selectSpec.Predicate).CountAsync(token); } + /// public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) { return await FindCore(expression).CountAsync(token); } + /// public override IQueryable FindQuery(ISpecification specification) { return FindCore(specification.Predicate); } + /// public override IQueryable FindQuery(Expression> expression) { return FindCore(expression); } + /// public override async Task FindAsync(object primaryKey, CancellationToken token = default) { - return await ObjectSet.FindAsync(new object[] { primaryKey }, token); + return (await ObjectSet.FindAsync(new object[] { primaryKey }, token))!; } + /// public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await FindCore(specification.Predicate).ToListAsync(token); } + /// public async override Task> FindAsync(Expression> expression, CancellationToken token = default) { return await FindCore(expression).ToListAsync(token); } + /// public async override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { IQueryable query; @@ -223,6 +272,7 @@ public async override Task> FindAsync(IPagedSpecificatio return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)); } + /// public async override Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 1, CancellationToken token = default) @@ -238,6 +288,8 @@ public async override Task> FindAsync(Expression public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) { @@ -253,6 +305,7 @@ public override IQueryable FindQuery(Expression> ex return query.Skip((pageNumber - 1) * pageSize).Take(pageSize); } + /// public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending) { @@ -268,32 +321,40 @@ public override IQueryable FindQuery(Expression> ex return query; } + /// public override IQueryable FindQuery(IPagedSpecification specification) { - return this.FindQuery(specification.Predicate, specification.OrderByExpression, + return this.FindQuery(specification.Predicate, specification.OrderByExpression, specification.OrderByAscending, specification.PageNumber, specification.PageSize); } + /// public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return await FindCore(expression).SingleOrDefaultAsync(token); + return (await FindCore(expression).SingleOrDefaultAsync(token))!; } + /// public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await FindCore(specification.Predicate).SingleOrDefaultAsync(token); + return (await FindCore(specification.Predicate).SingleOrDefaultAsync(token))!; } + /// public async override Task AnyAsync(Expression> expression, CancellationToken token = default) { return await FindCore(expression).AnyAsync(token); } + /// public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await FindCore(specification.Predicate).AnyAsync(token); } + /// + /// Gets the for the configured data store, resolved through the . + /// protected internal RCommonDbContext ObjectContext { get @@ -302,11 +363,18 @@ protected internal RCommonDbContext ObjectContext } } + /// + /// Persists all pending changes in the to the database. + /// + /// A cancellation token to observe. + /// The number of rows affected by the save operation. + /// Thrown when the underlying save operation fails. private async Task SaveAsync(CancellationToken token = default) { int affected = 0; try { + // acceptAllChangesOnSuccess is set to true so EF resets tracking after a successful save affected = await ObjectContext.SaveChangesAsync(true, token); } catch (ApplicationException ex) diff --git a/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs b/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs index 1dd3f630..fb09e9e8 100644 --- a/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs +++ b/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs @@ -15,12 +15,24 @@ namespace RCommon { /// - /// Implementation of for Entity Framework. + /// Implementation of that configures Entity Framework Core + /// persistence services in the dependency injection container. /// + /// + /// Upon construction, this builder registers as the default + /// implementation for , , + /// , and . + /// public class EFCorePerisistenceBuilder : IEFCorePersistenceBuilder { private readonly IServiceCollection _services; + /// + /// Initializes a new instance of and registers + /// EF Core repository services in the provided service collection. + /// + /// The to register services with. + /// Thrown when is null. public EFCorePerisistenceBuilder(IServiceCollection services) { _services = services ?? throw new ArgumentNullException(nameof(services)); @@ -32,13 +44,23 @@ public EFCorePerisistenceBuilder(IServiceCollection services) services.AddTransient(typeof(IGraphRepository<>), typeof(EFCoreRepository<>)); } + /// public IServiceCollection Services => _services; + /// + /// Registers a -derived DbContext with the specified data store name and options. + /// + /// The type of the DbContext to register. Must derive from . + /// A unique name identifying this data store for resolution via . + /// An optional action to configure the . + /// The builder instance for fluent chaining. + /// Thrown when is null or empty. public IEFCorePersistenceBuilder AddDbContext(string dataStoreName, Action? options = null) where TDbContext : RCommonDbContext { Guard.Against(dataStoreName.IsNullOrEmpty(), "You must set a name for the Data Store"); + // Register the factory, map the concrete DbContext type to the data store name, and add the DbContext with scoped lifetime _services.TryAddTransient(); _services.Configure(options => options.Register(dataStoreName)); _services.AddDbContext(options, ServiceLifetime.Scoped); @@ -46,6 +68,11 @@ public IEFCorePersistenceBuilder AddDbContext(string dataStoreName, return this; } + /// + /// Sets the default data store used when no explicit data store name is specified. + /// + /// An action to configure . + /// The builder instance for fluent chaining. public IPersistenceBuilder SetDefaultDataStore(Action options) { _services.Configure(options); diff --git a/Src/RCommon.EfCore/IEFCorePersistenceBuilder.cs b/Src/RCommon.EfCore/IEFCorePersistenceBuilder.cs index 25b78162..878a92a8 100644 --- a/Src/RCommon.EfCore/IEFCorePersistenceBuilder.cs +++ b/Src/RCommon.EfCore/IEFCorePersistenceBuilder.cs @@ -8,8 +8,22 @@ namespace RCommon { + /// + /// Defines the fluent builder interface for configuring Entity Framework Core persistence in RCommon. + /// + /// + /// Extends to add EF Core-specific configuration such as + /// registering instances with named data stores. + /// public interface IEFCorePersistenceBuilder : IPersistenceBuilder { + /// + /// Registers a -derived DbContext with the specified data store name and options. + /// + /// The type of the DbContext to register. Must derive from . + /// A unique name identifying this data store for resolution via . + /// An optional action to configure the for this context. + /// The builder instance for fluent chaining. IEFCorePersistenceBuilder AddDbContext(string dataStoreName, Action? options) where TDbContext : RCommonDbContext; } } diff --git a/Src/RCommon.EfCore/RCommonDbContext.cs b/Src/RCommon.EfCore/RCommonDbContext.cs index f2182897..a15fca1e 100644 --- a/Src/RCommon.EfCore/RCommonDbContext.cs +++ b/Src/RCommon.EfCore/RCommonDbContext.cs @@ -13,9 +13,22 @@ namespace RCommon.Persistence.EFCore { + /// + /// Abstract base class for all EF Core DbContexts used within the RCommon persistence layer. + /// + /// + /// Implements to provide a uniform abstraction over data stores, + /// allowing the to resolve named DbContext instances. + /// Derive from this class instead of directly when using RCommon. + /// public abstract class RCommonDbContext : DbContext, IDataStore { + /// + /// Initializes a new instance of with the specified options. + /// + /// The used to configure this context. + /// Thrown when is null. public RCommonDbContext(DbContextOptions options) : base(options) { @@ -28,6 +41,10 @@ public RCommonDbContext(DbContextOptions options) + /// + /// Gets the underlying for this context. + /// + /// The associated with the current database. public DbConnection GetDbConnection() { return base.Database.GetDbConnection(); diff --git a/Src/RCommon.EfCore/README.md b/Src/RCommon.EfCore/README.md index 87278aef..d07687df 100644 --- a/Src/RCommon.EfCore/README.md +++ b/Src/RCommon.EfCore/README.md @@ -1,3 +1,94 @@ - # RCommon.EFCore +# RCommon.EFCore -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Entity Framework Core implementation of the RCommon persistence abstractions. Provides a fully-featured repository with LINQ queries, eager loading, change tracking, and automatic domain event integration -- all backed by EF Core's `DbContext`. + +## Features + +- `EFCoreRepository` implementing `IGraphRepository`, `ILinqRepository`, `IReadOnlyRepository`, and `IWriteOnlyRepository` +- Full `IQueryable` support for composable LINQ queries at the domain layer +- Eager loading via `Include` / `ThenInclude` mapped to EF Core's `IIncludableQueryable` +- Configurable change tracking (enable/disable per repository via the `Tracking` property) +- Paginated query results with ordering support +- Bulk delete via `ExecuteDeleteAsync` for expression-based batch operations +- Named data store support for multi-database scenarios through `IDataStoreFactory` +- `RCommonDbContext` base class implementing `IDataStore` for seamless factory resolution +- Fluent DI configuration to register DbContexts as named data stores +- Automatic entity event tracking for domain event dispatching on add, update, and delete +- Targets .NET 8, .NET 9, and .NET 10 + +## Installation + +```shell +dotnet add package RCommon.EFCore +``` + +## Usage + +```csharp +// Configure in Program.cs or Startup +builder.Services.AddRCommon() + .WithPersistence(ef => + { + ef.AddDbContext("ApplicationDb", options => + options.UseSqlServer(builder.Configuration.GetConnectionString("ApplicationDb"))); + + ef.SetDefaultDataStore(defaults => + defaults.DefaultDataStoreName = "ApplicationDb"); + }); +``` + +Your `DbContext` must inherit from `RCommonDbContext`: + +```csharp +public class ApplicationDbContext : RCommonDbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet Customers => Set(); +} +``` + +Then inject and use the repository abstractions: + +```csharp +public class OrderService +{ + private readonly IGraphRepository _orderRepo; + + public OrderService(IGraphRepository orderRepo) + { + _orderRepo = orderRepo; + } + + public async Task> GetCustomerOrdersAsync(int customerId) + { + _orderRepo.Include(o => o.LineItems); + return await _orderRepo.FindAsync(o => o.CustomerId == customerId); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `EFCoreRepository` | Concrete repository backed by EF Core with full CRUD, LINQ, eager loading, and change tracking | +| `RCommonDbContext` | Abstract `DbContext` base class implementing `IDataStore` for named data store resolution | +| `EFCorePerisistenceBuilder` | Fluent builder for registering EF Core DbContexts and repository services in DI | +| `IEFCorePersistenceBuilder` | Builder interface exposing `AddDbContext()` for registering named DbContexts | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Persistence](https://www.nuget.org/packages/RCommon.Persistence) - Core persistence abstractions (required dependency) +- [RCommon.Dapper](https://www.nuget.org/packages/RCommon.Dapper) - Dapper implementation +- [RCommon.Linq2Db](https://www.nuget.org/packages/RCommon.Linq2Db) - Linq2Db implementation + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Emailing/EmailEventArgs.cs b/Src/RCommon.Emailing/EmailEventArgs.cs index 127650d3..747bb0ca 100644 --- a/Src/RCommon.Emailing/EmailEventArgs.cs +++ b/Src/RCommon.Emailing/EmailEventArgs.cs @@ -7,13 +7,23 @@ namespace RCommon.Emailing { + /// + /// Event arguments for email-related events, carrying the that was sent. + /// public class EmailEventArgs : EventArgs { + /// + /// Initializes a new instance of the class. + /// + /// The mail message associated with this event. public EmailEventArgs(MailMessage mailMessage) { MailMessage=mailMessage; } + /// + /// Gets the associated with this event. + /// public MailMessage MailMessage { get; } } } diff --git a/Src/RCommon.Emailing/EmailingBuilderExtensions.cs b/Src/RCommon.Emailing/EmailingBuilderExtensions.cs index 261819ce..4a31f700 100644 --- a/Src/RCommon.Emailing/EmailingBuilderExtensions.cs +++ b/Src/RCommon.Emailing/EmailingBuilderExtensions.cs @@ -9,9 +9,18 @@ namespace RCommon { + /// + /// Extension methods for that register SMTP-based email services. + /// public static class EmailingBuilderExtensions { - + /// + /// Registers as the implementation + /// and configures the SMTP settings via the provided delegate. + /// + /// The RCommon builder to configure. + /// A delegate to configure . + /// The same instance for fluent chaining. public static IRCommonBuilder WithSmtpEmailServices(this IRCommonBuilder config, Action emailSettings) { config.Services.Configure(emailSettings); diff --git a/Src/RCommon.Emailing/IEmailService.cs b/Src/RCommon.Emailing/IEmailService.cs index 6fc111ce..3ffe059a 100644 --- a/Src/RCommon.Emailing/IEmailService.cs +++ b/Src/RCommon.Emailing/IEmailService.cs @@ -4,11 +4,27 @@ namespace RCommon.Emailing { + /// + /// Defines a service for sending email messages, with support for both synchronous and asynchronous delivery. + /// public interface IEmailService { + /// + /// Occurs after an email has been successfully sent. + /// event EventHandler EmailSent; + /// + /// Sends the specified synchronously. + /// + /// The to send. void SendEmail(MailMessage message); + + /// + /// Sends the specified asynchronously. + /// + /// The to send. + /// A representing the asynchronous send operation. Task SendEmailAsync(MailMessage message); } } diff --git a/Src/RCommon.Emailing/README.md b/Src/RCommon.Emailing/README.md index ac41ef07..612f4aee 100644 --- a/Src/RCommon.Emailing/README.md +++ b/Src/RCommon.Emailing/README.md @@ -1,3 +1,88 @@ - # RCommon.Emailing +# RCommon.Emailing -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +An email service abstraction for .NET with a built-in SMTP implementation, designed to integrate with the RCommon dependency injection builder pattern. + +## Features + +- `IEmailService` abstraction for sending email via `System.Net.Mail.MailMessage` +- Synchronous and asynchronous send methods +- Built-in SMTP implementation (`SmtpEmailService`) using `System.Net.Mail.SmtpClient` +- Configurable SMTP settings including host, port, SSL, credentials, and default sender +- `EmailSent` event for post-send notifications +- Fluent DI registration through the `AddRCommon()` builder + +## Installation + +```shell +dotnet add package RCommon.Emailing +``` + +## Usage + +```csharp +using RCommon; + +services.AddRCommon() + .WithSmtpEmailServices(settings => + { + settings.Host = "smtp.example.com"; + settings.Port = 587; + settings.EnableSsl = true; + settings.UserName = "user@example.com"; + settings.Password = "password"; + settings.FromEmailDefault = "noreply@example.com"; + settings.FromNameDefault = "My Application"; + }); +``` + +Then inject and use `IEmailService`: + +```csharp +using RCommon.Emailing; +using System.Net.Mail; + +public class NotificationService +{ + private readonly IEmailService _emailService; + + public NotificationService(IEmailService emailService) + { + _emailService = emailService; + } + + public async Task SendWelcomeEmailAsync(string toAddress) + { + var message = new MailMessage("noreply@example.com", toAddress) + { + Subject = "Welcome!", + Body = "

Welcome to our app

", + IsBodyHtml = true + }; + + await _emailService.SendEmailAsync(message); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IEmailService` | Abstraction for sending email with sync and async methods | +| `SmtpEmailService` | SMTP-based implementation using `System.Net.Mail.SmtpClient` | +| `SmtpEmailSettings` | Configuration for SMTP host, port, SSL, credentials, and default sender | +| `EmailEventArgs` | Event args carrying the `MailMessage` after a successful send | +| `EmailingBuilderExtensions` | Provides `WithSmtpEmailServices()` for the RCommon builder | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.SendGrid](https://www.nuget.org/packages/RCommon.SendGrid) - SendGrid implementation of `IEmailService` +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs b/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs index b94b4f59..96fa8342 100644 --- a/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs +++ b/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs @@ -11,10 +11,21 @@ namespace RCommon.Emailing.Smtp { + /// + /// Implementation of that sends email using the built-in . + /// + /// + /// SMTP connection settings are provided via . + /// The is disposed after each send to release resources. + /// public class SmtpEmailService : IEmailService { private readonly SmtpEmailSettings _settings; + /// + /// Initializes a new instance of the class. + /// + /// The SMTP configuration options. public SmtpEmailService(IOptions settings) { _settings=settings.Value; @@ -35,7 +46,7 @@ public void SendEmail(MailMessage message) using (var smtp = new SmtpClient()) { smtp.Credentials = new NetworkCredential(this._settings.UserName, this._settings.Password); - smtp.Host = this._settings.Host; + smtp.Host = this._settings.Host!; smtp.Port = this._settings.Port; smtp.EnableSsl = this._settings.EnableSsl; smtp.Send(message); @@ -62,7 +73,13 @@ public async Task SendEmailAsync(MailMessage message) /// /// Occurs after an e-mail has been sent. The sender is the MailMessage object. /// - public event EventHandler EmailSent; + /// + public event EventHandler? EmailSent; + + /// + /// Raises the event if any subscribers are attached. + /// + /// The mail message that was sent. private void OnEmailSent(MailMessage message) { if (EmailSent != null) diff --git a/Src/RCommon.Emailing/Smtp/SmtpEmailSettings.cs b/Src/RCommon.Emailing/Smtp/SmtpEmailSettings.cs index ee114e3e..a1db43f4 100644 --- a/Src/RCommon.Emailing/Smtp/SmtpEmailSettings.cs +++ b/Src/RCommon.Emailing/Smtp/SmtpEmailSettings.cs @@ -4,20 +4,54 @@ namespace RCommon.Emailing.Smtp { + /// + /// Configuration settings for the . + /// Typically bound from an application configuration section (e.g., appsettings.json). + /// public class SmtpEmailSettings { + /// + /// Initializes a new instance of the class. + /// public SmtpEmailSettings() { } - public string UserName { get; set; } - public string Password { get; set; } + /// + /// Gets or sets the SMTP authentication user name. + /// + public string? UserName { get; set; } + + /// + /// Gets or sets the SMTP authentication password. + /// + public string? Password { get; set; } + + /// + /// Gets or sets a value indicating whether SSL is enabled for the SMTP connection. + /// public bool EnableSsl { get; set; } + + /// + /// Gets or sets the SMTP server port number. + /// public int Port { get; set; } - public string Host { get; set; } - public string FromEmailDefault { get; set; } - public string FromNameDefault { get; set; } + + /// + /// Gets or sets the SMTP server host name or IP address. + /// + public string? Host { get; set; } + + /// + /// Gets or sets the default sender email address used when no explicit "From" address is specified. + /// + public string? FromEmailDefault { get; set; } + + /// + /// Gets or sets the default sender display name used when no explicit "From" name is specified. + /// + public string? FromNameDefault { get; set; } } } diff --git a/Src/RCommon.Entities/AuditedEntity.cs b/Src/RCommon.Entities/AuditedEntity.cs index f5b9681e..3ef7b933 100644 --- a/Src/RCommon.Entities/AuditedEntity.cs +++ b/Src/RCommon.Entities/AuditedEntity.cs @@ -6,21 +6,52 @@ namespace RCommon.Entities { + /// + /// Abstract base class for entities that track creation and modification audit information. + /// Uses as the base (no explicit primary key type). + /// + /// The type representing the user who created the entity. + /// The type representing the user who last modified the entity. + /// + /// public abstract class AuditedEntity : BusinessEntity, IAuditedEntity { + /// public DateTime? DateCreated { get; set; } + + /// public TCreatedByUser? CreatedBy { get; set; } + + /// public DateTime? DateLastModified { get; set; } + + /// public TLastModifiedByUser? LastModifiedBy { get; set; } } + /// + /// Abstract base class for entities that track creation and modification audit information + /// with an explicit strongly-typed primary key. + /// + /// The type of the entity's primary key. Must implement . + /// The type representing the user who created the entity. + /// The type representing the user who last modified the entity. + /// + /// public abstract class AuditedEntity : BusinessEntity, IAuditedEntity, IAuditedEntity where TKey : IEquatable { + /// public DateTime? DateCreated { get; set; } + + /// public TCreatedByUser? CreatedBy { get; set; } + + /// public DateTime? DateLastModified { get; set; } + + /// public TLastModifiedByUser? LastModifiedBy { get; set; } } } diff --git a/Src/RCommon.Entities/BusinessEntity.cs b/Src/RCommon.Entities/BusinessEntity.cs index 866dae26..f5693b40 100644 --- a/Src/RCommon.Entities/BusinessEntity.cs +++ b/Src/RCommon.Entities/BusinessEntity.cs @@ -8,21 +8,41 @@ namespace RCommon.Entities { /// + /// + /// Provides a base implementation for domain entities with support for transactional (local) + /// event tracking. Local events are accumulated on the entity and can be emitted via an + /// during persistence operations. + /// [Serializable] public abstract class BusinessEntity : IBusinessEntity { private bool _allowEventTracking = true; - private List _localEvents; + private List _localEvents = new List(); + /// + /// Initializes a new instance of . + /// public BusinessEntity() { + // Ensure the local events list is initialized (null-coalesce guards against serialization scenarios) _localEvents = _localEvents ?? new List(); } - - public event EventHandler TransactionalEventAdded; - public event EventHandler TransactionalEventRemoved; - public event EventHandler TransactionalEventsCleared; + + /// + /// Occurs when a transactional event is added to this entity. + /// + public event EventHandler? TransactionalEventAdded; + + /// + /// Occurs when a transactional event is removed from this entity. + /// + public event EventHandler? TransactionalEventRemoved; + + /// + /// Occurs when all transactional events are cleared from this entity. + /// + public event EventHandler? TransactionalEventsCleared; /// @@ -31,58 +51,80 @@ public override string ToString() return $"[ENTITY: {GetType().Name}] Keys = {GetKeys().GetDelimitedString(',')}"; } + /// public abstract object[] GetKeys(); + /// public bool EntityEquals(IBusinessEntity other) { return this.BinaryEquals(other); } + /// [NotMapped] - public IReadOnlyCollection LocalEvents => _localEvents?.AsReadOnly(); + public IReadOnlyCollection LocalEvents => _localEvents.AsReadOnly(); + /// [NotMapped] public bool AllowEventTracking { get => _allowEventTracking; set => _allowEventTracking = value; } + /// public void AddLocalEvent(ISerializableEvent eventItem) { _localEvents.Add(eventItem); this.OnLocalEventsAdded(new TransactionalEventsChangedEventArgs(this, eventItem)); } + /// public void RemoveLocalEvent(ISerializableEvent eventItem) { _localEvents?.Remove(eventItem); this.OnLocalEventsRemoved(new TransactionalEventsChangedEventArgs(this, eventItem)); } + /// public void ClearLocalEvents() { _localEvents?.Clear(); this.OnLocalEventsCleared(new TransactionalEventsClearedEventArgs(this)); } + /// + /// Raises the event. + /// + /// The event arguments containing the entity and the added event. protected void OnLocalEventsAdded(TransactionalEventsChangedEventArgs args) { - EventHandler handler = TransactionalEventAdded; + // Capture delegate to a local variable for thread safety + EventHandler? handler = TransactionalEventAdded; if (handler != null) { handler(this, args); } } + /// + /// Raises the event. + /// + /// The event arguments containing the entity and the removed event. protected void OnLocalEventsRemoved(TransactionalEventsChangedEventArgs args) { - EventHandler handler = TransactionalEventRemoved; + // Capture delegate to a local variable for thread safety + EventHandler? handler = TransactionalEventRemoved; if (handler != null) { handler(this, args); } } + /// + /// Raises the event. + /// + /// The event arguments containing the entity whose events were cleared. protected void OnLocalEventsCleared(TransactionalEventsClearedEventArgs args) { - EventHandler handler = TransactionalEventsCleared; + // Capture delegate to a local variable for thread safety + EventHandler? handler = TransactionalEventsCleared; if (handler != null) { handler(this, args); @@ -96,18 +138,26 @@ public abstract class BusinessEntity : BusinessEntity, IBusinessEntity { /// - public virtual TKey Id { get; protected set; } + public virtual TKey Id { get; protected set; } = default!; + /// + /// Initializes a new instance of with a default key. + /// protected BusinessEntity() { } + /// + /// Initializes a new instance of with the specified key. + /// + /// The primary key value for this entity. protected BusinessEntity(TKey id) { Id = id; } + /// public override object[] GetKeys() { return new object[] { Id }; diff --git a/Src/RCommon.Entities/EntityNotFoundException.cs b/Src/RCommon.Entities/EntityNotFoundException.cs index 88e9082a..d7dcfa85 100644 --- a/Src/RCommon.Entities/EntityNotFoundException.cs +++ b/Src/RCommon.Entities/EntityNotFoundException.cs @@ -4,19 +4,20 @@ namespace RCommon.Entities { /// - /// This exception is thrown if an entity excepted to be found but not found. + /// Exception thrown when an entity expected to be found does not exist. /// + /// public class EntityNotFoundException : GeneralException { /// /// Type of the entity. /// - public Type EntityType { get; set; } + public Type? EntityType { get; set; } /// /// Id of the Entity. /// - public object Id { get; set; } + public object? Id { get; set; } /// /// Creates a new object. @@ -29,6 +30,7 @@ public EntityNotFoundException() /// /// Creates a new object. /// + /// The type of the entity that was not found. public EntityNotFoundException(Type entityType) : this(entityType, null, null) { @@ -38,21 +40,26 @@ public EntityNotFoundException(Type entityType) /// /// Creates a new object. /// - public EntityNotFoundException(Type entityType, object id) + /// The type of the entity that was not found. + /// The identifier of the entity that was not found. + public EntityNotFoundException(Type entityType, object? id) : this(entityType, id, null) { } /// - /// Creates a new object. + /// Creates a new object with an auto-generated message. /// - public EntityNotFoundException(Type entityType, object id, Exception innerException) + /// The type of the entity that was not found. + /// The identifier of the entity that was not found, or null if unknown. + /// The inner exception, or null if none. + public EntityNotFoundException(Type entityType, object? id, Exception? innerException) : base( id == null ? $"There is no such an entity given given id. Entity type: {entityType.FullName}" : $"There is no such an entity. Entity type: {entityType.FullName}, id: {id}", - innerException) + innerException!) { EntityType = entityType; Id = id; diff --git a/Src/RCommon.Entities/IAuditedEntity.cs b/Src/RCommon.Entities/IAuditedEntity.cs index 8b0f44e7..b466a1f1 100644 --- a/Src/RCommon.Entities/IAuditedEntity.cs +++ b/Src/RCommon.Entities/IAuditedEntity.cs @@ -2,18 +2,47 @@ namespace RCommon.Entities { - public interface IAuditedEntity + /// + /// Defines the contract for an entity that captures audit information including + /// who created and last modified it, and when. + /// + /// The type representing the user who created the entity. + /// The type representing the user who last modified the entity. + /// + public interface IAuditedEntity : IBusinessEntity { + /// + /// Gets or sets the user who created this entity. + /// TCreatedByUser? CreatedBy { get; set; } + + /// + /// Gets or sets the date and time when this entity was created. + /// DateTime? DateCreated { get; set; } + + /// + /// Gets or sets the date and time when this entity was last modified. + /// DateTime? DateLastModified { get; set; } + + /// + /// Gets or sets the user who last modified this entity. + /// TLastModifiedByUser? LastModifiedBy { get; set; } } - public interface IAuditedEntity + /// + /// Extends with a strongly-typed primary key. + /// + /// The type of the entity's primary key. + /// The type representing the user who created the entity. + /// The type representing the user who last modified the entity. + /// + public interface IAuditedEntity : IAuditedEntity, IBusinessEntity { - + } } diff --git a/Src/RCommon.Entities/IBusinessEntity.cs b/Src/RCommon.Entities/IBusinessEntity.cs index 75406e30..c87a9fb7 100644 --- a/Src/RCommon.Entities/IBusinessEntity.cs +++ b/Src/RCommon.Entities/IBusinessEntity.cs @@ -13,14 +13,36 @@ public interface IBusinessEntity : ITrackedEntity /// /// Returns an array of ordered keys for this entity. /// - /// + /// An object array containing the entity's key values in order. object[] GetKeys(); + /// + /// Gets the read-only collection of local (transactional) events accumulated on this entity. + /// IReadOnlyCollection LocalEvents { get; } + /// + /// Adds a transactional event to this entity's local event collection. + /// + /// The serializable event to add. void AddLocalEvent(ISerializableEvent eventItem); + + /// + /// Removes all transactional events from this entity's local event collection. + /// void ClearLocalEvents(); + + /// + /// Determines whether this entity is equal to another using binary comparison. + /// + /// The other entity to compare against. + /// true if the entities are equal; otherwise, false. bool EntityEquals(IBusinessEntity other); + + /// + /// Removes a specific transactional event from this entity's local event collection. + /// + /// The serializable event to remove. void RemoveLocalEvent(ISerializableEvent eventItem); } diff --git a/Src/RCommon.Entities/IEntityEventTracker.cs b/Src/RCommon.Entities/IEntityEventTracker.cs index 8f73e508..a8e822b8 100644 --- a/Src/RCommon.Entities/IEntityEventTracker.cs +++ b/Src/RCommon.Entities/IEntityEventTracker.cs @@ -4,6 +4,11 @@ namespace RCommon.Entities { + /// + /// Defines a mechanism for tracking entities and emitting their transactional (local) events + /// through the event routing infrastructure. + /// + /// public interface IEntityEventTracker { /// @@ -14,7 +19,7 @@ public interface IEntityEventTracker /// /// Adds an entity that can be tracked for any new events associated with it. /// - /// + /// The business entity to track for transactional events. void AddEntity(IBusinessEntity entity); /// diff --git a/Src/RCommon.Entities/ITrackedEntity.cs b/Src/RCommon.Entities/ITrackedEntity.cs index 1d90e3b4..817f8b84 100644 --- a/Src/RCommon.Entities/ITrackedEntity.cs +++ b/Src/RCommon.Entities/ITrackedEntity.cs @@ -6,8 +6,16 @@ namespace RCommon.Entities { + /// + /// Marks an entity as capable of participating in event tracking. + /// When is true, the entity's local events + /// can be collected and emitted by an . + /// public interface ITrackedEntity { + /// + /// Gets or sets a value indicating whether this entity should have its events tracked. + /// bool AllowEventTracking { get; set; } } } diff --git a/Src/RCommon.Entities/InMemoryEntityEventTracker.cs b/Src/RCommon.Entities/InMemoryEntityEventTracker.cs index fa640928..9cd1db0d 100644 --- a/Src/RCommon.Entities/InMemoryEntityEventTracker.cs +++ b/Src/RCommon.Entities/InMemoryEntityEventTracker.cs @@ -8,35 +8,60 @@ namespace RCommon.Entities { + /// + /// In-memory implementation of that collects entities and + /// emits their transactional events through an . + /// + /// + /// Entities are held in memory for the lifetime of this tracker instance. When + /// is called, the tracker traverses each entity's + /// object graph to collect all local events and routes them via the configured + /// . + /// public class InMemoryEntityEventTracker : IEntityEventTracker { private readonly ICollection _businessEntities = new List(); private readonly IEventRouter _eventRouter; + /// + /// Initializes a new instance of . + /// + /// The event router used to dispatch collected transactional events. + /// Thrown when is null. public InMemoryEntityEventTracker(IEventRouter eventRouter) { this._eventRouter = eventRouter ?? throw new ArgumentNullException(nameof(eventRouter)); } + /// public void AddEntity(IBusinessEntity entity) { - Guard.Against(entity == null, $"Entity of type {entity.GetGenericTypeName()} cannot be null"); + Guard.Against(entity == null, $"Entity of type {entity?.GetGenericTypeName()} cannot be null"); - if (entity.AllowEventTracking) + // Only track entities that have opted in to event tracking + if (entity!.AllowEventTracking) { _businessEntities.Add(entity); } - + } + /// public ICollection TrackedEntities { get => _businessEntities; } + /// + /// + /// Traverses the object graph of each tracked entity to discover nested + /// instances, collects their local events, and routes all events through the . + /// public async Task EmitTransactionalEventsAsync() { + // Walk each tracked root entity and traverse its object graph for nested IBusinessEntity instances foreach (var entity in this._businessEntities) { var entityGraph = entity.TraverseGraphFor(); + // Collect local events from every entity in the graph (root + children) foreach (var graphEntity in entityGraph) { _eventRouter.AddTransactionalEvents(graphEntity.LocalEvents); diff --git a/Src/RCommon.Entities/RCommon.Entities.csproj b/Src/RCommon.Entities/RCommon.Entities.csproj index d33ace5a..ae880720 100644 --- a/Src/RCommon.Entities/RCommon.Entities.csproj +++ b/Src/RCommon.Entities/RCommon.Entities.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Entities https://rcommon.com diff --git a/Src/RCommon.Entities/README.md b/Src/RCommon.Entities/README.md index 329fce1b..b246633c 100644 --- a/Src/RCommon.Entities/README.md +++ b/Src/RCommon.Entities/README.md @@ -1,3 +1,92 @@ # RCommon.Entities -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Domain entity base classes for the RCommon framework, providing a strongly-typed `BusinessEntity` base with built-in transactional event tracking, auditing support via `AuditedEntity`, and an `IEntityEventTracker` that collects and emits entity events through the event routing infrastructure. + +## Features + +- `BusinessEntity` and `BusinessEntity` abstract base classes with composite and single-key support +- Built-in transactional (local) event accumulation on entities via `AddLocalEvent`, `RemoveLocalEvent`, and `ClearLocalEvents` +- Entity-level event notifications (`TransactionalEventAdded`, `TransactionalEventRemoved`, `TransactionalEventsCleared`) for observing event changes +- `AuditedEntity` base classes that track `CreatedBy`, `DateCreated`, `LastModifiedBy`, and `DateLastModified` with flexible user types +- `ITrackedEntity` interface for opting entities into event tracking +- `IEntityEventTracker` and `InMemoryEntityEventTracker` for collecting entity events across object graphs and routing them through `IEventRouter` +- `EntityNotFoundException` for consistent "entity not found" error handling with type and ID context + +## Installation + +```shell +dotnet add package RCommon.Entities +``` + +## Usage + +```csharp +using RCommon.Entities; +using RCommon.Models.Events; + +// Define a domain entity with a GUID key +public class Order : BusinessEntity +{ + public string ProductName { get; set; } + public int Quantity { get; set; } + + public void Submit() + { + // Add a transactional event that will be emitted on persistence + AddLocalEvent(new OrderSubmittedEvent { OrderId = Id }); + } +} + +// Define an audited entity tracking who created/modified it +public class Invoice : AuditedEntity +{ + public decimal Amount { get; set; } + public string Currency { get; set; } +} + +// Emit entity events through the event router +public class OrderService +{ + private readonly IEntityEventTracker _eventTracker; + + public OrderService(IEntityEventTracker eventTracker) + { + _eventTracker = eventTracker; + } + + public async Task ProcessOrderAsync(Order order) + { + order.Submit(); + _eventTracker.AddEntity(order); + await _eventTracker.EmitTransactionalEventsAsync(); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IBusinessEntity` | Base entity interface with composite key support and local event collection | +| `IBusinessEntity` | Entity interface with a single strongly-typed `Id` property | +| `BusinessEntity` | Abstract base class with transactional event tracking and entity equality | +| `BusinessEntity` | Generic base class adding a typed primary key to `BusinessEntity` | +| `IAuditedEntity` | Audit contract with created/modified user and timestamp properties | +| `AuditedEntity` | Base class combining `BusinessEntity` with full audit tracking | +| `ITrackedEntity` | Marks an entity as eligible for event tracking via `AllowEventTracking` | +| `IEntityEventTracker` | Collects tracked entities and emits their transactional events | +| `InMemoryEntityEventTracker` | In-memory implementation that traverses entity object graphs and routes events | +| `EntityNotFoundException` | Exception for when an expected entity does not exist, with type and ID context | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Foundation package with event bus, builder pattern, guards, and extensions +- [RCommon.Models](https://www.nuget.org/packages/RCommon.Models) - Shared models for CQRS commands, queries, events, pagination, and execution results + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Entities/TransactionalEventsChangedEventArgs.cs b/Src/RCommon.Entities/TransactionalEventsChangedEventArgs.cs index 55c0ec44..7a948d4f 100644 --- a/Src/RCommon.Entities/TransactionalEventsChangedEventArgs.cs +++ b/Src/RCommon.Entities/TransactionalEventsChangedEventArgs.cs @@ -7,15 +7,32 @@ namespace RCommon.Entities { + /// + /// Provides data for events raised when a transactional (local) event is added to + /// or removed from a . + /// + /// public class TransactionalEventsChangedEventArgs : EventArgs { + /// + /// Initializes a new instance of . + /// + /// The entity on which the transactional event changed. + /// The serializable event that was added or removed. public TransactionalEventsChangedEventArgs(IBusinessEntity entity, ISerializableEvent eventData) { Entity=entity; EventData=eventData; } + /// + /// Gets the entity on which the transactional event changed. + /// public IBusinessEntity Entity { get; } + + /// + /// Gets the serializable event that was added or removed. + /// public ISerializableEvent EventData { get; } } } diff --git a/Src/RCommon.Entities/TransactionalEventsClearedEventArgs.cs b/Src/RCommon.Entities/TransactionalEventsClearedEventArgs.cs index ce52c837..61d01ca0 100644 --- a/Src/RCommon.Entities/TransactionalEventsClearedEventArgs.cs +++ b/Src/RCommon.Entities/TransactionalEventsClearedEventArgs.cs @@ -7,13 +7,25 @@ namespace RCommon.Entities { + /// + /// Provides data for the event raised when all transactional (local) events + /// are cleared from a . + /// + /// public class TransactionalEventsClearedEventArgs : EventArgs { + /// + /// Initializes a new instance of . + /// + /// The entity whose transactional events were cleared. public TransactionalEventsClearedEventArgs(IBusinessEntity entity) { Entity = entity; } + /// + /// Gets the entity whose transactional events were cleared. + /// public IBusinessEntity Entity { get; } } } diff --git a/Src/RCommon.FluentValidation/FluentValidationBuilder.cs b/Src/RCommon.FluentValidation/FluentValidationBuilder.cs index 9c79c6f6..c4ef86ee 100644 --- a/Src/RCommon.FluentValidation/FluentValidationBuilder.cs +++ b/Src/RCommon.FluentValidation/FluentValidationBuilder.cs @@ -9,19 +9,37 @@ namespace RCommon.FluentValidation { + /// + /// Default implementation of that registers + /// the as the + /// in the DI container. + /// + /// + /// public class FluentValidationBuilder : IFluentValidationBuilder { + /// + /// Initializes a new instance of and registers + /// FluentValidation services into the DI container. + /// + /// The RCommon builder providing access to the . public FluentValidationBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers the as the + /// implementation with a scoped lifetime. + /// + /// The service collection to register into. protected void RegisterServices(IServiceCollection services) { services.AddScoped(); } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.FluentValidation/FluentValidationBuilderExtensions.cs b/Src/RCommon.FluentValidation/FluentValidationBuilderExtensions.cs index 4442a94b..d8a937ff 100644 --- a/Src/RCommon.FluentValidation/FluentValidationBuilderExtensions.cs +++ b/Src/RCommon.FluentValidation/FluentValidationBuilderExtensions.cs @@ -11,9 +11,19 @@ namespace RCommon.ApplicationServices { + /// + /// Provides extension methods on for registering + /// FluentValidation validators into the DI container. + /// public static class FluentValidationBuilderExtensions { + /// + /// Registers a specific FluentValidation validator for the given type with a scoped lifetime. + /// + /// The type being validated. + /// The implementation to register. + /// The FluentValidation builder instance. public static void AddValidator(this IFluentValidationBuilder builder) where TValidator : class, IValidator where T : class @@ -21,22 +31,47 @@ public static void AddValidator(this IFluentValidationBuilder bui builder.Services.AddScoped, TValidator>(); } - public static void AddValidatorsFromAssembly(this IFluentValidationBuilder builder, Assembly assembly, - ServiceLifetime lifetime = ServiceLifetime.Scoped, Func filter = null, + /// + /// Scans the specified assembly and registers all implementations found. + /// + /// The FluentValidation builder instance. + /// The assembly to scan for validator types. + /// The service lifetime for registered validators. Defaults to . + /// An optional filter to include or exclude specific scan results. + /// Whether to include internal (non-public) validator types. Defaults to . + public static void AddValidatorsFromAssembly(this IFluentValidationBuilder builder, Assembly assembly, + ServiceLifetime lifetime = ServiceLifetime.Scoped, Func? filter = null, bool includeInternalTypes = false) { builder.Services.AddValidatorsFromAssembly(assembly, lifetime, filter, includeInternalTypes); } + /// + /// Scans multiple assemblies and registers all implementations found. + /// + /// The FluentValidation builder instance. + /// The assemblies to scan for validator types. + /// The service lifetime for registered validators. Defaults to . + /// An optional filter to include or exclude specific scan results. + /// Whether to include internal (non-public) validator types. Defaults to . public static void AddValidatorsFromAssemblies(this IFluentValidationBuilder builder, IEnumerable assemblies, ServiceLifetime lifetime = ServiceLifetime.Scoped, - Func filter = null, bool includeInternalTypes = false) + Func? filter = null, bool includeInternalTypes = false) { builder.Services.AddValidatorsFromAssemblies(assemblies, lifetime, filter, includeInternalTypes); } + /// + /// Scans the assembly containing the specified and registers all + /// implementations found. + /// + /// The FluentValidation builder instance. + /// A type whose containing assembly will be scanned. + /// The service lifetime for registered validators. Defaults to . + /// An optional filter to include or exclude specific scan results. + /// Whether to include internal (non-public) validator types. Defaults to . public static void AddValidatorsFromAssemblyContaining(this IFluentValidationBuilder builder, Type type, - ServiceLifetime lifetime = ServiceLifetime.Scoped, Func filter = null, + ServiceLifetime lifetime = ServiceLifetime.Scoped, Func? filter = null, bool includeInternalTypes = false) { builder.Services.AddValidatorsFromAssemblyContaining(type, lifetime, filter, includeInternalTypes); diff --git a/Src/RCommon.FluentValidation/FluentValidationProvider.cs b/Src/RCommon.FluentValidation/FluentValidationProvider.cs index 212613d5..364820d2 100644 --- a/Src/RCommon.FluentValidation/FluentValidationProvider.cs +++ b/Src/RCommon.FluentValidation/FluentValidationProvider.cs @@ -12,17 +12,44 @@ namespace RCommon.FluentValidation { - public class FluentValidationProvider : IValidationProvider + /// + /// Implements using the FluentValidation library. + /// Resolves registered instances from the DI container + /// and executes them against the target object. + /// + /// + /// + public class FluentValidationProvider : IValidationProvider { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The logger for recording validation warnings and errors. + /// The service provider used to resolve validator instances. public FluentValidationProvider(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } + /// + /// Validates the specified target object by resolving and executing all registered + /// instances for the target's type. + /// + /// The type of the object being validated. + /// The object to validate. + /// + /// When , a + /// is thrown if any validation failures are found. + /// + /// A token to cancel the async operation. + /// A containing any validation faults. + /// + /// Thrown when validation fails and is . + /// public async Task ValidateAsync(T target, bool throwOnFaults, CancellationToken cancellationToken = default) where T : class { @@ -30,13 +57,17 @@ public async Task ValidateAsync(T target, bool throwOnFaul using (var scope = _serviceProvider.CreateScope()) { + // Build the closed generic IValidator type using the runtime type of the target + // so that validators registered for derived types are also resolved var type = target.GetType(); var validatorType = typeof(IValidator<>).MakeGenericType(type); var untypedValidators = scope.ServiceProvider.GetServices(validatorType); - + Guard.IsNotNull(untypedValidators, nameof(untypedValidators)); - var validationResults = await ExecuteValidationAsync(target, untypedValidators, cancellationToken); // TODO: Need a better way than passing in object[] + var validationResults = await ExecuteValidationAsync(target, untypedValidators!, cancellationToken); // TODO: Need a better way than passing in object[] + + // Flatten all validation errors from all validators into a single list var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); if (failures.Count != 0) @@ -44,6 +75,7 @@ public async Task ValidateAsync(T target, bool throwOnFaul _logger.LogWarning("Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}", target.GetGenericTypeName(), target, failures); string message = $"Validation Errors"; + // Map FluentValidation failures to RCommon ValidationFault instances var faults = new List(); foreach (var failure in failures) { @@ -62,17 +94,28 @@ public async Task ValidateAsync(T target, bool throwOnFaul return outcome; } + /// + /// Executes all provided validators against the target object concurrently. + /// + /// The type of the object being validated. + /// The object to validate. + /// The collection of untyped validator instances resolved from DI. + /// A token to cancel the async operation. + /// An array of from all executed validators. private async Task ExecuteValidationAsync(T target, IEnumerable validators, CancellationToken cancellationToken = default) where T : class { if (validators.Any()) - { + { var context = new ValidationContext(target); + + // Run all validators in parallel via Task.WhenAll, casting each to the non-generic IValidator interface var validationResults = await Task.WhenAll(validators.Select(v => ((IValidator)v).ValidateAsync(context, cancellationToken))); return validationResults; } else { + // No validators registered for this type; return an empty result set return new List().ToArray(); } } diff --git a/Src/RCommon.FluentValidation/IFluentValidationBuilder.cs b/Src/RCommon.FluentValidation/IFluentValidationBuilder.cs index ade2711b..9efa232d 100644 --- a/Src/RCommon.FluentValidation/IFluentValidationBuilder.cs +++ b/Src/RCommon.FluentValidation/IFluentValidationBuilder.cs @@ -3,8 +3,17 @@ namespace RCommon.FluentValidation { + /// + /// Builder interface for configuring validation using the FluentValidation library + /// within the RCommon framework. + /// + /// + /// public interface IFluentValidationBuilder : IValidationBuilder { - IServiceCollection Services { get; } + /// + /// Gets the used to register FluentValidation services and validators. + /// + new IServiceCollection Services { get; } } } diff --git a/Src/RCommon.FluentValidation/README.md b/Src/RCommon.FluentValidation/README.md index b06a181f..68ccf5c0 100644 --- a/Src/RCommon.FluentValidation/README.md +++ b/Src/RCommon.FluentValidation/README.md @@ -1,3 +1,104 @@ # RCommon.FluentValidation -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +FluentValidation integration for RCommon's `IValidationProvider` abstraction. This package bridges the FluentValidation library into RCommon's validation pipeline, resolving registered `IValidator` instances from the DI container and executing them with support for automatic CQRS command/query validation. + +## Features + +- Implements `IValidationProvider` using the FluentValidation library +- Resolves and executes all registered `IValidator` instances for a given type from DI +- Runs multiple validators concurrently via `Task.WhenAll` +- Maps FluentValidation failures to RCommon's `ValidationOutcome` and `ValidationFault` types +- Supports optional automatic validation of CQRS commands and queries via `CqrsValidationOptions` +- Assembly scanning to auto-register all validators in one or more assemblies +- Configurable `throwOnFaults` behavior to throw `ValidationException` on failure +- Registered as a scoped service in the DI container + +## Installation + +```shell +dotnet add package RCommon.FluentValidation +``` + +## Usage + +Register FluentValidation through the RCommon builder and add your validators: + +```csharp +using RCommon; +using RCommon.FluentValidation; + +services.AddRCommon(builder => +{ + builder.WithValidation(validation => + { + validation.AddValidator(); + + // Or scan an assembly for all validators + validation.AddValidatorsFromAssembly(typeof(CreateOrderDtoValidator).Assembly); + }); +}); +``` + +To enable automatic validation in the CQRS pipeline: + +```csharp +services.AddRCommon(builder => +{ + builder.WithValidation(options => + { + options.ValidateCommands = true; + options.ValidateQueries = true; + }); +}); +``` + +Inject and use `IValidationProvider` directly when needed: + +```csharp +public class OrderService +{ + private readonly IValidationProvider _validator; + + public OrderService(IValidationProvider validator) + { + _validator = validator; + } + + public async Task CreateOrder(CreateOrderDto dto) + { + var outcome = await _validator.ValidateAsync(dto, throwOnFaults: true); + + // If throwOnFaults is false, inspect the outcome manually + if (!outcome.IsValid) + { + foreach (var fault in outcome.Errors) + { + Console.WriteLine($"{fault.PropertyName}: {fault.ErrorMessage}"); + } + } + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `FluentValidationProvider` | `IValidationProvider` implementation that resolves and runs FluentValidation validators | +| `FluentValidationBuilder` | Registers `FluentValidationProvider` into the DI container | +| `IFluentValidationBuilder` | Builder interface exposing `IServiceCollection` for validator registration | +| `FluentValidationBuilderExtensions` | Provides `AddValidator()`, `AddValidatorsFromAssembly()`, and assembly scanning methods | +| `ValidationBuilderExtensions` | Provides `WithValidation()` on `IRCommonBuilder` for pipeline registration | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.ApplicationServices](https://www.nuget.org/packages/RCommon.ApplicationServices) - Core validation abstractions (IValidationProvider, ValidationOutcome, ValidationFault) +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - RCommon framework core and DI builder + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs b/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs index 2fffe14f..37cbc065 100644 --- a/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs +++ b/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs @@ -7,9 +7,21 @@ namespace RCommon.ApplicationServices { + /// + /// Provides extension methods on for registering validation + /// into the RCommon configuration pipeline. + /// public static class ValidationBuilderExtensions { + /// + /// Registers validation using the specified builder with default + /// . + /// + /// An implementation such as + /// FluentValidationBuilder. + /// The RCommon builder instance. + /// The for further chaining. public static IRCommonBuilder WithValidation(this IRCommonBuilder builder) where T : IValidationBuilder { @@ -17,12 +29,25 @@ public static IRCommonBuilder WithValidation(this IRCommonBuilder builder) return WithValidation(builder, x => { }); } + /// + /// Registers validation using the specified builder and configures + /// for CQRS pipeline validation behavior. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to configure . + /// The for further chaining. + /// + /// This method uses to instantiate the builder, + /// passing the as the constructor argument. The builder's constructor + /// is expected to register its validation services into the DI container. + /// public static IRCommonBuilder WithValidation(this IRCommonBuilder builder, Action actions) where T : IValidationBuilder { - // Event Handling Configurations - var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Instantiate the validation builder via reflection; the constructor registers validation services into DI + var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; builder.Services.Configure(actions); return builder; } diff --git a/Src/RCommon.Json/IJsonBuilder.cs b/Src/RCommon.Json/IJsonBuilder.cs index ff4ee589..a3875737 100644 --- a/Src/RCommon.Json/IJsonBuilder.cs +++ b/Src/RCommon.Json/IJsonBuilder.cs @@ -7,8 +7,17 @@ namespace RCommon.Json { + /// + /// Defines the contract for configuring JSON serialization within the RCommon framework. + /// Implementations register a specific JSON serialization library (e.g., Newtonsoft.Json or System.Text.Json) + /// into the dependency injection container. + /// + /// public interface IJsonBuilder { + /// + /// Gets the used to register JSON serialization services. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Json/IJsonSerializer.cs b/Src/RCommon.Json/IJsonSerializer.cs index b53cc0bb..efb8189a 100644 --- a/Src/RCommon.Json/IJsonSerializer.cs +++ b/Src/RCommon.Json/IJsonSerializer.cs @@ -6,14 +6,47 @@ namespace RCommon.Json { + /// + /// Provides an abstraction for JSON serialization and deserialization operations. + /// Implementations wrap a specific JSON library (e.g., Newtonsoft.Json or System.Text.Json). + /// + /// + /// public interface IJsonSerializer { + /// + /// Serializes the specified object to a JSON string. + /// + /// The object to serialize. + /// Optional serialization options such as camel casing and indentation. + /// A JSON string representation of the object. public string Serialize(object obj, JsonSerializeOptions? options = null); + /// + /// Serializes the specified object to a JSON string using the provided type information. + /// + /// The object to serialize. + /// The to use during serialization, which may differ from the runtime type. + /// Optional serialization options such as camel casing and indentation. + /// A JSON string representation of the object. public string Serialize(object obj, Type type, JsonSerializeOptions? options = null); - public T Deserialize(string json, JsonDeserializeOptions? options = null); + /// + /// Deserializes a JSON string to an object of type . + /// + /// The target type to deserialize to. + /// The JSON string to deserialize. + /// Optional deserialization options such as camel casing. + /// The deserialized object of type , or null if the JSON represents a null value. + public T? Deserialize(string json, JsonDeserializeOptions? options = null); - public object Deserialize(string json, Type type, JsonDeserializeOptions? options = null); + /// + /// Deserializes a JSON string to an object of the specified . + /// + /// The JSON string to deserialize. + /// The to deserialize the JSON into. + /// Optional deserialization options such as camel casing. + /// The deserialized object, or null if the JSON represents a null value. + public object? Deserialize(string json, Type type, JsonDeserializeOptions? options = null); } } diff --git a/Src/RCommon.Json/JsonBuilderExtensions.cs b/Src/RCommon.Json/JsonBuilderExtensions.cs index 940b52bc..53077341 100644 --- a/Src/RCommon.Json/JsonBuilderExtensions.cs +++ b/Src/RCommon.Json/JsonBuilderExtensions.cs @@ -8,14 +8,37 @@ namespace RCommon.Json { + /// + /// Provides extension methods on for registering JSON serialization + /// into the RCommon configuration pipeline. + /// + /// + /// All overloads delegate to the primary overload that accepts serialize options, deserialize options, + /// and a builder configuration action. Overloads that omit parameters supply no-op defaults. + /// public static class JsonBuilderExtensions { + /// + /// Registers JSON serialization using the specified builder with default options. + /// + /// An implementation such as + /// JsonNetBuilder or TextJsonBuilder. + /// The RCommon builder instance. + /// The for further chaining. public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder) where T : IJsonBuilder { return WithJsonSerialization(builder, x => { }, x => { }, x => { }); } + /// + /// Registers JSON serialization with custom serialize and deserialize options. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to configure . + /// An action to configure . + /// The for further chaining. public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions, Action deSerializeOptions) where T : IJsonBuilder @@ -23,12 +46,26 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil return WithJsonSerialization(builder, serializeOptions, deSerializeOptions, x => { }); } + /// + /// Registers JSON serialization with custom serialize options only. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to configure . + /// The for further chaining. public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions) where T : IJsonBuilder { return WithJsonSerialization(builder, serializeOptions, x => { }, x => { }); } + /// + /// Registers JSON serialization with custom deserialize options only. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to configure . + /// The for further chaining. public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action deSerializeOptions) where T : IJsonBuilder @@ -36,6 +73,13 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil return WithJsonSerialization(builder, x => { }, deSerializeOptions, x => { }); } + /// + /// Registers JSON serialization with a custom builder configuration action. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to further configure the builder instance. + /// The for further chaining. public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action actions) where T : IJsonBuilder { @@ -43,7 +87,22 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil return WithJsonSerialization(builder, x => { }, x => { }, actions); } - public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions, + /// + /// Primary overload that registers JSON serialization with full control over serialize options, + /// deserialize options, and builder-specific configuration. + /// + /// An implementation. + /// The RCommon builder instance. + /// An action to configure . + /// An action to configure . + /// An action to further configure the builder instance. + /// The for further chaining. + /// + /// This method uses to instantiate the builder, + /// passing the as the constructor argument. The builder's constructor + /// is expected to register its serialization services into the DI container. + /// + public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions, Action deSerializeOptions, Action actions) where T : IJsonBuilder { @@ -51,7 +110,9 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil Guard.IsNotNull(deSerializeOptions, nameof(deSerializeOptions)); Guard.IsNotNull(actions, nameof(actions)); builder.Services.Configure(serializeOptions); - var jsonConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + + // Instantiate the builder via reflection; the constructor registers serializer services into DI + var jsonConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(jsonConfig); return builder; } diff --git a/Src/RCommon.Json/JsonDeserializeOptions.cs b/Src/RCommon.Json/JsonDeserializeOptions.cs index 6cb9fc60..3568440f 100644 --- a/Src/RCommon.Json/JsonDeserializeOptions.cs +++ b/Src/RCommon.Json/JsonDeserializeOptions.cs @@ -6,13 +6,26 @@ namespace RCommon.Json { + /// + /// Configuration options applied during JSON deserialization. + /// + /// + /// public class JsonDeserializeOptions { + /// + /// Initializes a new instance of with default values. + /// defaults to . + /// public JsonDeserializeOptions() { - this.CamelCase = true; + this.CamelCase = true; } + /// + /// Gets or sets a value indicating whether property names should use camelCase naming policy + /// during deserialization. Defaults to . + /// public bool CamelCase { get; set; } } } diff --git a/Src/RCommon.Json/JsonSerializeOptions.cs b/Src/RCommon.Json/JsonSerializeOptions.cs index 2996532c..ef1f56ce 100644 --- a/Src/RCommon.Json/JsonSerializeOptions.cs +++ b/Src/RCommon.Json/JsonSerializeOptions.cs @@ -6,15 +6,33 @@ namespace RCommon.Json { + /// + /// Configuration options applied during JSON serialization. + /// + /// + /// public class JsonSerializeOptions { + /// + /// Initializes a new instance of with default values. + /// defaults to and defaults to . + /// public JsonSerializeOptions() { this.CamelCase = true; this.Indented = false; } + /// + /// Gets or sets a value indicating whether property names should use camelCase naming policy + /// during serialization. Defaults to . + /// public bool CamelCase { get; set; } + + /// + /// Gets or sets a value indicating whether the serialized JSON output should be indented + /// for readability. Defaults to . + /// public bool Indented { get; set; } } } diff --git a/Src/RCommon.Json/RCommon.Json.csproj b/Src/RCommon.Json/RCommon.Json.csproj index 604fa240..b015fae6 100644 --- a/Src/RCommon.Json/RCommon.Json.csproj +++ b/Src/RCommon.Json/RCommon.Json.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Json https://rcommon.com diff --git a/Src/RCommon.Json/README.md b/Src/RCommon.Json/README.md index b5d234cc..5eb99a1d 100644 --- a/Src/RCommon.Json/README.md +++ b/Src/RCommon.Json/README.md @@ -1,3 +1,84 @@ - # RCommon.Json +# RCommon.Json -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides the JSON serialization abstraction layer for RCommon. This package defines the `IJsonSerializer` interface and configuration options, allowing your application to serialize and deserialize JSON without coupling to a specific library. + +## Features + +- `IJsonSerializer` interface with generic and non-generic serialize/deserialize methods +- `JsonSerializeOptions` for controlling camelCase naming and indented output +- `JsonDeserializeOptions` for controlling camelCase naming during deserialization +- `IJsonBuilder` interface for pluggable DI registration of JSON providers +- Fluent `WithJsonSerialization()` extension method on `IRCommonBuilder` for easy setup + +## Installation + +```shell +dotnet add package RCommon.Json +``` + +## Usage + +This package is typically not used directly. Instead, install a concrete implementation such as `RCommon.JsonNet` or `RCommon.SystemTextJson` and register it through the RCommon builder: + +```csharp +using RCommon; +using RCommon.Json; + +services.AddRCommon(builder => +{ + // Use one of the concrete implementations: + // builder.WithJsonSerialization(); + // builder.WithJsonSerialization(); +}); +``` + +Once registered, inject `IJsonSerializer` anywhere in your application: + +```csharp +public class MyService +{ + private readonly IJsonSerializer _serializer; + + public MyService(IJsonSerializer serializer) + { + _serializer = serializer; + } + + public string ToJson(Order order) + { + return _serializer.Serialize(order, new JsonSerializeOptions + { + CamelCase = true, + Indented = true + }); + } + + public Order FromJson(string json) + { + return _serializer.Deserialize(json); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IJsonSerializer` | Abstraction for JSON serialization and deserialization operations | +| `JsonSerializeOptions` | Options for camelCase naming and indented formatting during serialization | +| `JsonDeserializeOptions` | Options for camelCase naming during deserialization | +| `IJsonBuilder` | Builder interface for registering a JSON provider into DI | +| `JsonBuilderExtensions` | Extension methods providing `WithJsonSerialization()` on `IRCommonBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.JsonNet](https://www.nuget.org/packages/RCommon.JsonNet) - Newtonsoft.Json implementation of IJsonSerializer +- [RCommon.SystemTextJson](https://www.nuget.org/packages/RCommon.SystemTextJson) - System.Text.Json implementation of IJsonSerializer + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.JsonNet/IJsonNetBuilder.cs b/Src/RCommon.JsonNet/IJsonNetBuilder.cs index 4d62325d..89b5902c 100644 --- a/Src/RCommon.JsonNet/IJsonNetBuilder.cs +++ b/Src/RCommon.JsonNet/IJsonNetBuilder.cs @@ -7,6 +7,11 @@ namespace RCommon.JsonNet { + /// + /// Builder interface for configuring JSON serialization using the Newtonsoft.Json (Json.NET) library. + /// + /// + /// public interface IJsonNetBuilder : IJsonBuilder { } diff --git a/Src/RCommon.JsonNet/IJsonNetBuilderExtensions.cs b/Src/RCommon.JsonNet/IJsonNetBuilderExtensions.cs index 9c59c7dc..bf56d47a 100644 --- a/Src/RCommon.JsonNet/IJsonNetBuilderExtensions.cs +++ b/Src/RCommon.JsonNet/IJsonNetBuilderExtensions.cs @@ -9,8 +9,17 @@ namespace RCommon.JsonNet { + /// + /// Provides extension methods for to configure Newtonsoft.Json settings. + /// public static class IJsonNetBuilderExtensions { + /// + /// Configures the underlying used by the Newtonsoft.Json serializer. + /// + /// The Json.NET builder instance. + /// An action to configure . + /// The for further chaining. public static IJsonNetBuilder Configure(this IJsonNetBuilder builder, Action options) { builder.Services.Configure(options); diff --git a/Src/RCommon.JsonNet/JsonNetBuilder.cs b/Src/RCommon.JsonNet/JsonNetBuilder.cs index 77d3b785..c5d1b08b 100644 --- a/Src/RCommon.JsonNet/JsonNetBuilder.cs +++ b/Src/RCommon.JsonNet/JsonNetBuilder.cs @@ -8,19 +8,35 @@ namespace RCommon.JsonNet { + /// + /// Default implementation of that registers + /// the Newtonsoft.Json-based into the DI container. + /// + /// + /// public class JsonNetBuilder : IJsonNetBuilder { + /// + /// Initializes a new instance of and registers JSON serialization services. + /// + /// The RCommon builder providing access to the . public JsonNetBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers the as the implementation + /// with a transient lifetime. + /// + /// The service collection to register into. protected void RegisterServices(IServiceCollection services) { services.AddTransient(); } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.JsonNet/JsonNetSerializer.cs b/Src/RCommon.JsonNet/JsonNetSerializer.cs index 4c551423..2dba230c 100644 --- a/Src/RCommon.JsonNet/JsonNetSerializer.cs +++ b/Src/RCommon.JsonNet/JsonNetSerializer.cs @@ -10,17 +10,34 @@ namespace RCommon.JsonNet { + /// + /// Implements using the Newtonsoft.Json (Json.NET) library. + /// Supports per-call overrides for camel-case naming and indented formatting through + /// and . + /// + /// + /// The underlying are injected via the options pattern + /// and may be mutated per-call when options are provided. This means per-call options + /// modify the shared settings instance. + /// public class JsonNetSerializer : IJsonSerializer { private readonly JsonSerializerSettings _settings; + /// + /// Initializes a new instance of with the configured + /// . + /// + /// The injected Newtonsoft.Json serializer settings. public JsonNetSerializer(IOptions options) { _settings = options.Value; } - public T Deserialize(string json, JsonDeserializeOptions? options = null) + /// + public T? Deserialize(string json, JsonDeserializeOptions? options = null) { + // Apply camelCase contract resolver when requested if (options != null && options.CamelCase) { _settings.ContractResolver = new CamelCasePropertyNamesContractResolver(); @@ -28,8 +45,10 @@ public T Deserialize(string json, JsonDeserializeOptions? options = null) return JsonConvert.DeserializeObject(json, _settings); } - public object Deserialize(string json, Type type, JsonDeserializeOptions? options = null) + /// + public object? Deserialize(string json, Type type, JsonDeserializeOptions? options = null) { + // Apply camelCase contract resolver when requested if (options != null && options.CamelCase) { _settings.ContractResolver = new CamelCasePropertyNamesContractResolver(); @@ -37,28 +56,34 @@ public object Deserialize(string json, Type type, JsonDeserializeOptions? option return JsonConvert.DeserializeObject(json, type, _settings); } + /// public string Serialize(object obj, JsonSerializeOptions? options = null) { + // Apply camelCase contract resolver when requested if (options != null && options.CamelCase) { _settings.ContractResolver = new CamelCasePropertyNamesContractResolver(); } + // Enable indented formatting when requested if (options != null && options.Indented) { _settings.Formatting = Formatting.Indented; } - + return JsonConvert.SerializeObject(obj, _settings); } + /// public string Serialize(object obj, Type type, JsonSerializeOptions? options = null) { + // Apply camelCase contract resolver when requested if (options != null && options.CamelCase) { _settings.ContractResolver = new CamelCasePropertyNamesContractResolver(); } + // Enable indented formatting when requested if (options != null && options.Indented) { _settings.Formatting = Formatting.Indented; diff --git a/Src/RCommon.JsonNet/RCommon.JsonNet.csproj b/Src/RCommon.JsonNet/RCommon.JsonNet.csproj index 5f6c190a..5d06a50e 100644 --- a/Src/RCommon.JsonNet/RCommon.JsonNet.csproj +++ b/Src/RCommon.JsonNet/RCommon.JsonNet.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.JsonNet https://rcommon.com diff --git a/Src/RCommon.JsonNet/README.md b/Src/RCommon.JsonNet/README.md index 406fef59..eb2820a7 100644 --- a/Src/RCommon.JsonNet/README.md +++ b/Src/RCommon.JsonNet/README.md @@ -1,3 +1,79 @@ # RCommon.JsonNet -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Newtonsoft.Json (Json.NET) implementation of RCommon's `IJsonSerializer` abstraction. This package registers `JsonNetSerializer` into the dependency injection container and provides fluent configuration of `JsonSerializerSettings`. + +## Features + +- Implements `IJsonSerializer` using Newtonsoft.Json for serialization and deserialization +- Per-call options for camelCase property naming and indented formatting +- Fluent configuration of `JsonSerializerSettings` through the builder pattern +- Integrates with RCommon's `AddRCommon()` / `WithJsonSerialization()` pipeline +- Registered as a transient service in the DI container + +## Installation + +```shell +dotnet add package RCommon.JsonNet +``` + +## Usage + +Register the Newtonsoft.Json serializer through the RCommon builder: + +```csharp +using RCommon; +using RCommon.JsonNet; + +services.AddRCommon(builder => +{ + builder.WithJsonSerialization(serializer => + { + serializer.Configure(settings => + { + settings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; + settings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore; + }); + }); +}); +``` + +Then inject and use `IJsonSerializer` in your services: + +```csharp +public class OrderService +{ + private readonly IJsonSerializer _serializer; + + public OrderService(IJsonSerializer serializer) + { + _serializer = serializer; + } + + public string SerializeOrder(Order order) + { + return _serializer.Serialize(order); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `JsonNetSerializer` | `IJsonSerializer` implementation backed by Newtonsoft.Json | +| `JsonNetBuilder` | Registers `JsonNetSerializer` into the DI container | +| `IJsonNetBuilder` | Builder interface for Newtonsoft.Json-specific configuration | +| `IJsonNetBuilderExtensions` | Provides `Configure(Action)` for customizing serializer settings | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Json](https://www.nuget.org/packages/RCommon.Json) - JSON serialization abstractions (IJsonSerializer, options) +- [RCommon.SystemTextJson](https://www.nuget.org/packages/RCommon.SystemTextJson) - Alternative implementation using System.Text.Json + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs index 2246ee68..6faa1618 100644 --- a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs +++ b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs @@ -20,13 +20,30 @@ namespace RCommon.Persistence.Linq2Db.Crud { + /// + /// A concrete repository implementation using Linq2Db for CRUD operations and LINQ-based querying. + /// + /// The entity type managed by this repository. Must implement . + /// + /// Queries are built against from the underlying . + /// Supports eager loading via and + /// using Linq2Db's LoadWith/ThenLoad API. + /// public class Linq2DbRepository : LinqRepositoryBase where TEntity : class, IBusinessEntity { - private IQueryable _repositoryQuery; - private ILoadWithQueryable _includableQueryable; + private IQueryable? _repositoryQuery; + private ILoadWithQueryable? _includableQueryable; private readonly IDataStoreFactory _dataStoreFactory; + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any parameter is null. public Linq2DbRepository(IDataStoreFactory dataStoreFactory, ILoggerFactory logger, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) @@ -54,6 +71,9 @@ public Linq2DbRepository(IDataStoreFactory dataStoreFactory, } + /// + /// Gets the for the configured data store, resolved through the . + /// protected internal RCommonDataConnection DataConnection { get @@ -62,6 +82,9 @@ protected internal RCommonDataConnection DataConnection } } + /// + /// Gets the Linq2Db from the current for direct table operations. + /// protected ITable ObjectSet { get @@ -70,19 +93,36 @@ protected ITable ObjectSet } } + /// + /// Adds an eager-loading path for the specified navigation property using Linq2Db's LoadWith API. + /// + /// An expression selecting the navigation property to include. + /// This repository instance for fluent chaining of additional includes. public override IEagerLoadableQueryable Include(Expression> path) { - _includableQueryable = RepositoryQuery.LoadWith(path); + _includableQueryable = RepositoryQuery.LoadWith(path!); return this; } + /// + /// Adds a subsequent eager-loading path for a nested navigation property after a prior call, + /// using Linq2Db's ThenLoad API. + /// + /// The type of the previously included navigation property. + /// The type of the nested navigation property to include. + /// An expression selecting the nested navigation property to include. + /// This repository instance for fluent chaining. public override IEagerLoadableQueryable ThenInclude(Expression> path) { - _repositoryQuery = _includableQueryable.ThenLoad(path); + _repositoryQuery = _includableQueryable!.ThenLoad(path!); return this; } + /// + /// Gets the base used for all query operations. + /// Applies eager-loading expressions if any have been configured via . + /// protected override IQueryable RepositoryQuery { get @@ -92,7 +132,7 @@ protected override IQueryable RepositoryQuery _repositoryQuery = ObjectSet.AsQueryable(); } - // Start Eagerloading + // Override the base query with the eager-loaded queryable if includes have been configured if (_includableQueryable != null) { _repositoryQuery = _includableQueryable; @@ -101,13 +141,20 @@ protected override IQueryable RepositoryQuery } } + /// + /// Core query method that applies the given filter expression to the . + /// All find operations delegate to this method to build the filtered queryable. + /// + /// A predicate expression to filter entities. + /// An representing the filtered query. + /// Thrown when is null. private IQueryable FindCore(Expression> expression) { IQueryable queryable; try { Guard.Against(RepositoryQuery == null, "RepositoryQuery is null"); - queryable = RepositoryQuery.Where(expression); + queryable = RepositoryQuery!.Where(expression); } catch (ApplicationException exception) { @@ -118,50 +165,58 @@ private IQueryable FindCore(Expression> expression) } + /// public async override Task AddAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); await DataConnection.InsertAsync(entity, token: token); } + /// public async override Task AnyAsync(Expression> expression, CancellationToken token = default) { return await RepositoryQuery.AnyAsync(expression, token: token); } + /// public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await AnyAsync(specification.Predicate, token: token); } + /// public async override Task DeleteAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); await DataConnection.DeleteAsync(entity); } + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await FindQuery(expression).DeleteAsync(token); } + /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await DeleteManyAsync(specification.Predicate, token); } + /// public override IQueryable FindQuery(ISpecification specification) { return FindCore(specification.Predicate); } + /// public override IQueryable FindQuery(Expression> expression) { return FindCore(expression); } /// - /// This is not yet implemented due to Linq2Db's inability to find primary key or array of primary key. + /// This is not yet implemented due to Linq2Db's inability to find primary key or array of primary key. /// /// Value of Primary Key /// Cancellation Token @@ -174,16 +229,19 @@ public override async Task FindAsync(object primaryKey, CancellationTok //DataExtensions.RetrieveIdentity(IEnumerable } + /// public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await FindCore(specification.Predicate).ToListAsync(token); } + /// public async override Task> FindAsync(Expression> expression, CancellationToken token = default) { return await FindCore(expression).ToListAsync(token); } + /// public async override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { IQueryable query; @@ -198,6 +256,7 @@ public async override Task> FindAsync(IPagedSpecificatio return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)); } + /// public async override Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 1, CancellationToken token = default) @@ -214,7 +273,8 @@ public async override Task> FindAsync(Expression FindQuery(Expression> expression, Expression> orderByExpression, + /// + public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) { IQueryable query; @@ -229,12 +289,14 @@ public override IQueryable FindQuery(Expression> ex return query.Skip((pageNumber - 1) * pageSize).Take(pageSize); } + /// public override IQueryable FindQuery(IPagedSpecification specification) { return this.FindQuery(specification.Predicate, specification.OrderByExpression, specification.OrderByAscending, specification.PageNumber, specification.PageSize); } + /// public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending) { @@ -250,26 +312,31 @@ public override IQueryable FindQuery(Expression> ex return query; } + /// public async override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return await RepositoryQuery.SingleOrDefaultAsync(expression, token); + return (await RepositoryQuery.SingleOrDefaultAsync(expression, token))!; } + /// public async override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { return await FindSingleOrDefaultAsync(specification.Predicate, token); } + /// public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { return await GetCountAsync(selectSpec.Predicate, token); } + /// public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) { return await RepositoryQuery.CountAsync(expression, token); } + /// public async override Task UpdateAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); diff --git a/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs b/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs index 8161b3a5..66d5dd17 100644 --- a/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs +++ b/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs @@ -3,8 +3,22 @@ namespace RCommon.Persistence.Linq2Db { + /// + /// Defines the fluent builder interface for configuring Linq2Db-based persistence in RCommon. + /// + /// + /// Extends to add Linq2Db-specific configuration such as + /// registering -derived data connections with named data stores. + /// public interface ILinq2DbPersistenceBuilder: IPersistenceBuilder { + /// + /// Registers a Linq2Db data connection type with the specified data store name and configuration options. + /// + /// The type of the data connection. Must derive from . + /// A unique name identifying this data store for resolution via . + /// A factory function that receives the and existing , returning configured . + /// The builder instance for fluent chaining. ILinq2DbPersistenceBuilder AddDataConnection(string dataStoreName, Func options) where TDataConnection : RCommonDataConnection; } } diff --git a/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs b/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs index 8e2fa169..661cd16c 100644 --- a/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs +++ b/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs @@ -16,11 +16,26 @@ namespace RCommon.Persistence.Linq2Db { + /// + /// Implementation of that configures Linq2Db-based + /// persistence services in the dependency injection container. + /// + /// + /// Upon construction, this builder registers as the default + /// implementation for , , + /// and . + /// public class Linq2DbPersistenceBuilder : ILinq2DbPersistenceBuilder { private readonly IServiceCollection _services; + /// + /// Initializes a new instance of and registers + /// Linq2Db repository services in the provided service collection. + /// + /// The to register services with. + /// Thrown when is null. public Linq2DbPersistenceBuilder(IServiceCollection services) { _services = services ?? throw new ArgumentNullException(nameof(services)); @@ -31,20 +46,35 @@ public Linq2DbPersistenceBuilder(IServiceCollection services) services.AddTransient(typeof(ILinqRepository<>), typeof(Linq2DbRepository<>)); } + /// public IServiceCollection Services => _services; + /// + /// Registers a Linq2Db data connection type with the specified data store name and configuration options. + /// + /// The type of the data connection. Must derive from . + /// A unique name identifying this data store for resolution via . + /// A factory function that receives the and existing , returning configured . + /// The builder instance for fluent chaining. + /// Thrown when is null or empty, or when is null. public ILinq2DbPersistenceBuilder AddDataConnection(string dataStoreName, Func options) where TDataConnection : RCommonDataConnection { Guard.Against(dataStoreName.IsNullOrEmpty(), "You must set a name for the Data Store"); Guard.Against(options == null, "You must set options to a value in order for them to be useful"); + // Register the factory, map the concrete DataConnection type to the data store name, and add the Linq2Db context this._services.TryAddTransient(); this._services.Configure(options => options.Register(dataStoreName)); - this._services.AddLinqToDBContext(options); + this._services.AddLinqToDBContext(options!); return this; } + /// + /// Sets the default data store used when no explicit data store name is specified. + /// + /// An action to configure . + /// The builder instance for fluent chaining. public IPersistenceBuilder SetDefaultDataStore(Action options) { this._services.Configure(options); diff --git a/Src/RCommon.Linq2Db/RCommonDataConnection.cs b/Src/RCommon.Linq2Db/RCommonDataConnection.cs index ee60f542..3cd9f683 100644 --- a/Src/RCommon.Linq2Db/RCommonDataConnection.cs +++ b/Src/RCommon.Linq2Db/RCommonDataConnection.cs @@ -14,17 +14,33 @@ namespace RCommon.Persistence.Linq2Db { + /// + /// Base data connection class for Linq2Db integration with the RCommon persistence layer. + /// + /// + /// Implements to provide a uniform abstraction over data stores, + /// allowing the to resolve named Linq2Db data connections. + /// Derive from this class instead of directly when using RCommon. + /// public class RCommonDataConnection : DataConnection, IDataStore { + /// + /// Initializes a new instance of with the specified Linq2Db data options. + /// + /// The used to configure the Linq2Db connection. public RCommonDataConnection(DataOptions linq2DbOptions) :base(linq2DbOptions) { - + } - + + /// + /// Gets the underlying for this data connection. + /// + /// The managed by the Linq2Db . public DbConnection GetDbConnection() { return this.Connection; diff --git a/Src/RCommon.Linq2Db/README.md b/Src/RCommon.Linq2Db/README.md index 1a1dda7a..9b841091 100644 --- a/Src/RCommon.Linq2Db/README.md +++ b/Src/RCommon.Linq2Db/README.md @@ -1,3 +1,101 @@ - # RCommon.Linq2Db +# RCommon.Linq2Db -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Linq2Db implementation of the RCommon persistence abstractions. Provides a LINQ-enabled repository backed by Linq2Db's `DataConnection`, supporting composable queries, eager loading, pagination, and integration with the RCommon data store factory and domain event tracking. + +## Features + +- `Linq2DbRepository` implementing `ILinqRepository`, `IReadOnlyRepository`, and `IWriteOnlyRepository` +- Full `IQueryable` support built on Linq2Db's `ITable` for composable LINQ queries +- Eager loading via `Include` / `ThenInclude` mapped to Linq2Db's `LoadWith` / `ThenLoad` API +- Paginated query results with ordering support via `IPaginatedList` +- Expression-based and specification-based querying with `FindQuery` returning `IQueryable` +- Bulk delete via Linq2Db's `DeleteAsync` on queryable expressions +- Named data store support for multi-database scenarios through `IDataStoreFactory` +- `RCommonDataConnection` base class implementing `IDataStore` for seamless factory resolution +- Fluent DI configuration using `AddLinqToDBContext` under the hood +- Domain event tracking integrated into add, update, and delete operations +- Targets .NET 8, .NET 9, and .NET 10 + +## Installation + +```shell +dotnet add package RCommon.Linq2Db +``` + +## Usage + +```csharp +// Configure in Program.cs or Startup +builder.Services.AddRCommon() + .WithPersistence(linq2Db => + { + linq2Db.AddDataConnection("ApplicationDb", + (serviceProvider, options) => + options.UseSqlServer(builder.Configuration.GetConnectionString("ApplicationDb"))); + + linq2Db.SetDefaultDataStore(defaults => + defaults.DefaultDataStoreName = "ApplicationDb"); + }); +``` + +Your data connection must inherit from `RCommonDataConnection`: + +```csharp +public class ApplicationDataConnection : RCommonDataConnection +{ + public ApplicationDataConnection(DataOptions options) + : base(options) { } +} +``` + +Then inject and use the repository abstractions: + +```csharp +public class CustomerService +{ + private readonly ILinqRepository _customerRepo; + + public CustomerService(ILinqRepository customerRepo) + { + _customerRepo = customerRepo; + } + + public async Task> GetActiveCustomersAsync() + { + return await _customerRepo.FindAsync(c => c.IsActive); + } + + public async Task> GetCustomersPagedAsync(int page, int pageSize) + { + return await _customerRepo.FindAsync( + c => c.IsActive, + c => c.LastName, + orderByAscending: true, + pageNumber: page, + pageSize: pageSize); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `Linq2DbRepository` | Concrete repository using Linq2Db with full LINQ, eager loading, and CRUD support | +| `RCommonDataConnection` | Base `DataConnection` class implementing `IDataStore` for named data store resolution | +| `Linq2DbPersistenceBuilder` | Fluent builder for registering Linq2Db data connections and repository services in DI | +| `ILinq2DbPersistenceBuilder` | Builder interface exposing `AddDataConnection()` for registering named data connections | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Persistence](https://www.nuget.org/packages/RCommon.Persistence) - Core persistence abstractions (required dependency) +- [RCommon.EFCore](https://www.nuget.org/packages/RCommon.EFCore) - Entity Framework Core implementation +- [RCommon.Dapper](https://www.nuget.org/packages/RCommon.Dapper) - Dapper implementation + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.MassTransit/IMassTransitEventHandlingBuilder.cs b/Src/RCommon.MassTransit/IMassTransitEventHandlingBuilder.cs index 82e11f7f..5dc8077a 100644 --- a/Src/RCommon.MassTransit/IMassTransitEventHandlingBuilder.cs +++ b/Src/RCommon.MassTransit/IMassTransitEventHandlingBuilder.cs @@ -4,8 +4,13 @@ namespace RCommon.MassTransit { + /// + /// Builder interface for configuring MassTransit-based event handling within the RCommon framework. + /// Combines for RCommon event wiring with + /// for MassTransit bus configuration. + /// public interface IMassTransitEventHandlingBuilder : IEventHandlingBuilder, IBusRegistrationConfigurator { - + } } diff --git a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilder.cs b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilder.cs index fa046eaf..0d54ad39 100644 --- a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilder.cs +++ b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilder.cs @@ -11,16 +11,26 @@ namespace RCommon.MassTransit { + /// + /// Default implementation of that configures MassTransit + /// consumers and event handling services through the RCommon builder pipeline. + /// Inherits from to provide full MassTransit bus registration capabilities. + /// public class MassTransitEventHandlingBuilder : ServiceCollectionBusConfigurator, IMassTransitEventHandlingBuilder { + /// + /// Initializes a new instance of using the provided RCommon builder. + /// + /// The whose service collection is used for dependency registration. public MassTransitEventHandlingBuilder(IRCommonBuilder builder) :base(builder.Services) { Services = builder.Services; - + } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs index ded9de01..bc005fae 100644 --- a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs +++ b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs @@ -21,14 +21,19 @@ namespace RCommon { + /// + /// Extension methods for configuring MassTransit event handling within the RCommon builder pipeline. + /// public static class MassTransitEventHandlingBuilderExtensions { /// - /// Adds MassTransit and its dependencies to the , and allows consumers, sagas, and activities to be configured + /// Adds MassTransit and its dependencies to the , and allows consumers, sagas, and activities to be configured. /// - /// - /// - private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, Action configure = null) + /// The RCommon builder to register MassTransit services against. + /// Optional configuration action for . + /// The for further chaining. + /// Thrown if MassTransit has already been registered in this container. + private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, Action? configure = null) { if (builder.Services.Any(d => d.ServiceType == typeof(IBus))) { @@ -46,6 +51,10 @@ private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, A return builder.Services; } + /// + /// Registers the MassTransit hosted service, health checks, and host options required for bus lifetime management. + /// + /// The service collection to register services into. private static void AddHostedService(IServiceCollection collection) { collection.AddOptions(); @@ -57,6 +66,10 @@ private static void AddHostedService(IServiceCollection collection) collection.TryAddEnumerable(ServiceDescriptor.Singleton()); } + /// + /// Registers MassTransit instrumentation and monitoring options into the service collection. + /// + /// The service collection to register instrumentation services into. private static void AddInstrumentation(IServiceCollection collection) { collection.AddOptions(); @@ -64,12 +77,27 @@ private static void AddInstrumentation(IServiceCollection collection) } + /// + /// Configures MassTransit event handling with default settings. + /// + /// The implementation type. + /// The RCommon builder. + /// The for further chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder) where T : IMassTransitEventHandlingBuilder { return WithEventHandling(builder, x => { }); } + /// + /// Configures MassTransit event handling with custom builder actions. + /// Registers the generic as a scoped service + /// and wires up MassTransit via . + /// + /// The implementation type. + /// The RCommon builder. + /// Configuration delegate for MassTransit event handling. + /// The for further chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions) where T : IMassTransitEventHandlingBuilder { @@ -82,6 +110,13 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, return builder; } + /// + /// Registers a subscriber for a specific event type and adds the corresponding MassTransit consumer. + /// Also registers the event-to-producer subscription for correct event routing. + /// + /// The event type to subscribe to. Must implement . + /// The subscriber implementation that handles the event. + /// The MassTransit event handling builder. public static void AddSubscriber(this IMassTransitEventHandlingBuilder builder) where TEvent : class, ISerializableEvent where TEventHandler : class, ISubscriber diff --git a/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs b/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs index 1098e902..fea4de6e 100644 --- a/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs +++ b/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs @@ -11,6 +11,14 @@ namespace RCommon.MassTransit.Producers { + /// + /// An implementation that publishes events to all subscribed consumers + /// using MassTransit's method (fan-out pattern). + /// + /// + /// Use this producer when you want an event to be delivered to all consumers that are subscribed + /// to the event type. For point-to-point delivery, use instead. + /// public class PublishWithMassTransitEventProducer : IEventProducer { private readonly IBus _bus; @@ -18,6 +26,13 @@ public class PublishWithMassTransitEventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The MassTransit bus used to publish events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public PublishWithMassTransitEventProducer(IBus bus, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -27,12 +42,14 @@ public PublishWithMassTransitEventProducer(IBus bus, ILogger public async Task ProduceEventAsync(T @event, CancellationToken cancellationToken = default) where T : ISerializableEvent { try { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(T))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", @@ -40,6 +57,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT return; } + // Create a scoped service context for the publish operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs b/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs index a02b3437..1e75e23d 100644 --- a/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs +++ b/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs @@ -11,6 +11,14 @@ namespace RCommon.MassTransit.Producers { + /// + /// An implementation that sends events to a single consumer endpoint + /// using MassTransit's method (point-to-point pattern). + /// + /// + /// Use this producer for command-style messaging where only one consumer should process the event. + /// For fan-out delivery to all subscribers, use instead. + /// public class SendWithMassTransitEventProducer : IEventProducer { private readonly IBus _bus; @@ -18,6 +26,13 @@ public class SendWithMassTransitEventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The MassTransit bus used to send events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public SendWithMassTransitEventProducer(IBus bus, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -27,18 +42,22 @@ public SendWithMassTransitEventProducer(IBus bus, ILogger public async Task ProduceEventAsync(T @event, CancellationToken cancellationToken = default) where T : ISerializableEvent { try { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(T))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", new object[] { this.GetGenericTypeName(), typeof(T).Name }); return; } + + // Create a scoped service context for the send operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.MassTransit/README.md b/Src/RCommon.MassTransit/README.md index a5ef6c09..f5dd896f 100644 --- a/Src/RCommon.MassTransit/README.md +++ b/Src/RCommon.MassTransit/README.md @@ -1,3 +1,87 @@ - # RCommon.MassTransit +# RCommon.MassTransit -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Integrates [MassTransit](https://masstransit.io/) distributed messaging with RCommon's event handling system, allowing you to produce and consume events through MassTransit while programming against RCommon's `IEventProducer` and `ISubscriber` abstractions. + +## Features + +- Publish events to all subscribed consumers using MassTransit's fan-out (publish) semantics +- Send events to a single consumer endpoint using MassTransit's point-to-point (send) semantics +- Bridge MassTransit consumers to RCommon's `ISubscriber` abstraction for handler portability +- Event subscription routing ensures events are delivered only to their configured producers +- Full access to MassTransit's `IBusRegistrationConfigurator` for transport and consumer configuration +- Automatic hosted service, health check, and instrumentation registration + +## Installation + +```shell +dotnet add package RCommon.MassTransit +``` + +## Usage + +```csharp +using RCommon; +using RCommon.MassTransit; + +builder.Services.AddRCommon() + .WithEventHandling(eventHandling => + { + // Register subscribers that bridge MassTransit to RCommon + eventHandling.AddSubscriber(); + + // Configure MassTransit transports (full IBusRegistrationConfigurator access) + eventHandling.UsingRabbitMq((context, cfg) => + { + cfg.Host("localhost", "/", h => + { + h.Username("guest"); + h.Password("guest"); + }); + cfg.ConfigureEndpoints(context); + }); + }); +``` + +Produce events from application code: + +```csharp +public class OrderService +{ + private readonly IEventProducer _eventProducer; + + public OrderService(IEventProducer eventProducer) + { + _eventProducer = eventProducer; + } + + public async Task CreateOrderAsync(Order order) + { + // This publishes via MassTransit to all subscribed consumers + await _eventProducer.ProduceEventAsync(new OrderCreatedEvent(order)); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `PublishWithMassTransitEventProducer` | `IEventProducer` that publishes events to all consumers via `IBus.Publish` (fan-out) | +| `SendWithMassTransitEventProducer` | `IEventProducer` that sends events to a single endpoint via `IBus.Send` (point-to-point) | +| `MassTransitEventHandler` | MassTransit `IConsumer` that delegates to an RCommon `ISubscriber` | +| `IMassTransitEventHandlingBuilder` | Builder combining `IEventHandlingBuilder` with MassTransit's `IBusRegistrationConfigurator` | +| `MassTransitEventHandlingBuilder` | Default builder implementation for configuring MassTransit event handling | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions including `IEventProducer` and `ISubscriber` +- [RCommon.MediatR](https://www.nuget.org/packages/RCommon.MediatR) - MediatR integration for in-process event handling and mediator pattern +- [RCommon.Wolverine](https://www.nuget.org/packages/RCommon.Wolverine) - Wolverine integration for distributed messaging + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.MassTransit/Subscribers/IMassTransitEventHandler.cs b/Src/RCommon.MassTransit/Subscribers/IMassTransitEventHandler.cs index a5193a50..72f41e3d 100644 --- a/Src/RCommon.MassTransit/Subscribers/IMassTransitEventHandler.cs +++ b/Src/RCommon.MassTransit/Subscribers/IMassTransitEventHandler.cs @@ -8,11 +8,18 @@ namespace RCommon.MassTransit.Subscribers { + /// + /// Non-generic marker interface for MassTransit event handlers within the RCommon framework. + /// public interface IMassTransitEventHandler { } - public interface IMassTransitEventHandler : IMassTransitEventHandler + /// + /// Generic marker interface for MassTransit event handlers that process a specific distributed event type. + /// + /// The distributed event type to handle. Must implement . + public interface IMassTransitEventHandler : IMassTransitEventHandler where TDistributedEvent : class, ISerializableEvent { } diff --git a/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs b/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs index 5bbbf891..92434105 100644 --- a/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs +++ b/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs @@ -10,18 +10,32 @@ namespace RCommon.MassTransit.Subscribers { + /// + /// MassTransit consumer that bridges MassTransit message consumption to the RCommon abstraction. + /// Implements both and MassTransit's . + /// + /// The event type to consume. Must implement . public class MassTransitEventHandler : IMassTransitEventHandler, IConsumer where TEvent : class, ISerializableEvent { private readonly ILogger> _logger; private readonly ISubscriber _subscriber; + /// + /// Initializes a new instance of . + /// + /// Logger for diagnostic output. + /// The RCommon subscriber that handles the event. public MassTransitEventHandler(ILogger> logger, ISubscriber subscriber) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber)); } + /// + /// Consumes a MassTransit message and delegates to the registered . + /// + /// The MassTransit consume context containing the event message. public async Task Consume(ConsumeContext context) { _logger.LogDebug("{0} handling event {1}", new object[] { this.GetGenericTypeName(), context.Message }); diff --git a/Src/RCommon.Mediator/IMediatorAdapter.cs b/Src/RCommon.Mediator/IMediatorAdapter.cs index 8911678c..e0055692 100644 --- a/Src/RCommon.Mediator/IMediatorAdapter.cs +++ b/Src/RCommon.Mediator/IMediatorAdapter.cs @@ -6,13 +6,41 @@ namespace RCommon.Mediator { + /// + /// Defines an adapter interface that abstracts the underlying mediator implementation (e.g., MediatR, Wolverine). + /// + /// + /// Concrete implementations of this interface bridge RCommon's mediator abstraction to a specific + /// mediator library. This enables swapping mediator implementations without changing application code. + /// public interface IMediatorAdapter { - + /// + /// Sends a request to a single handler with no return value. + /// + /// The type of the request to send. + /// The request object to dispatch. + /// Optional token to cancel the operation. + /// A representing the asynchronous operation. Task Send(TRequest request, CancellationToken cancellationToken = default); + /// + /// Sends a request to a single handler and returns a response. + /// + /// The type of the request to send. + /// The type of the response expected from the handler. + /// The request object to dispatch. + /// Optional token to cancel the operation. + /// A containing the handler's response. Task Send(TRequest request, CancellationToken cancellationToken = default); + /// + /// Publishes a notification to all registered handlers. + /// + /// The type of the notification to publish. + /// The notification object to broadcast. + /// Optional token to cancel the operation. + /// A representing the asynchronous operation. Task Publish(TNotification notification, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Mediator/IMediatorBuilder.cs b/Src/RCommon.Mediator/IMediatorBuilder.cs index 1ffd4090..51bb4d03 100644 --- a/Src/RCommon.Mediator/IMediatorBuilder.cs +++ b/Src/RCommon.Mediator/IMediatorBuilder.cs @@ -7,8 +7,19 @@ namespace RCommon.Mediator { + /// + /// Defines the contract for configuring a mediator implementation within the RCommon framework. + /// + /// + /// Implementations of this interface are responsible for registering mediator-specific services + /// (e.g., MediatR or Wolverine) into the dependency injection container. Used in conjunction + /// with . + /// public interface IMediatorBuilder { + /// + /// Gets the used to register mediator-related services. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Mediator/IMediatorService.cs b/Src/RCommon.Mediator/IMediatorService.cs index cd4df058..124423df 100644 --- a/Src/RCommon.Mediator/IMediatorService.cs +++ b/Src/RCommon.Mediator/IMediatorService.cs @@ -2,12 +2,42 @@ namespace RCommon.Mediator { + /// + /// Provides a high-level mediator service for sending requests and publishing notifications. + /// + /// + /// This is the primary interface that application code should depend on for mediator operations. + /// It delegates to internally, decoupling consumers from the + /// underlying mediator implementation. + /// public interface IMediatorService { + /// + /// Publishes a notification to all registered handlers. + /// + /// The type of the notification to publish. + /// The notification object to broadcast to all subscribers. + /// Optional token to cancel the operation. + /// A representing the asynchronous operation. Task Publish(TNotification notification, CancellationToken cancellationToken = default); + /// + /// Sends a request to a single handler with no return value. + /// + /// The type of the request to send. + /// The request object to dispatch. + /// Optional token to cancel the operation. + /// A representing the asynchronous operation. Task Send(TRequest request, CancellationToken cancellationToken = default); + /// + /// Sends a request to a single handler and returns a response. + /// + /// The type of the request to send. + /// The type of the response expected from the handler. + /// The request object to dispatch. + /// Optional token to cancel the operation. + /// A containing the handler's response. Task Send(TRequest request, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/Src/RCommon.Mediator/MediatorBuilderExtensions.cs b/Src/RCommon.Mediator/MediatorBuilderExtensions.cs index 1f0979f8..fe70defd 100644 --- a/Src/RCommon.Mediator/MediatorBuilderExtensions.cs +++ b/Src/RCommon.Mediator/MediatorBuilderExtensions.cs @@ -11,22 +11,47 @@ namespace RCommon { + /// + /// Provides extension methods on for registering a mediator implementation. + /// public static class MediatorBuilderExtensions { + /// + /// Registers a mediator implementation using default configuration. + /// + /// + /// The implementation that configures the specific mediator library. + /// + /// The RCommon builder instance. + /// The for chaining additional configuration calls. public static IRCommonBuilder WithMediator(this IRCommonBuilder builder) where T : IMediatorBuilder { return WithMediator(builder, x => { }); } + /// + /// Registers a mediator implementation with custom configuration. + /// + /// + /// The implementation that configures the specific mediator library. + /// + /// The RCommon builder instance. + /// A delegate to configure the mediator builder of type . + /// The for chaining additional configuration calls. + /// + /// This method registers as the scoped implementation, + /// then creates the mediator builder via and invokes + /// the configuration delegate to allow library-specific setup. + /// public static IRCommonBuilder WithMediator(this IRCommonBuilder builder, Action actions) where T : IMediatorBuilder { builder.Services.AddScoped(); - // Event Handling Configurations - var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + // Create the mediator-specific builder by convention (expects a constructor accepting IRCommonBuilder) + var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(mediatorConfig); return builder; } diff --git a/Src/RCommon.Mediator/MediatorService.cs b/Src/RCommon.Mediator/MediatorService.cs index 49d17872..8e2bc4c5 100644 --- a/Src/RCommon.Mediator/MediatorService.cs +++ b/Src/RCommon.Mediator/MediatorService.cs @@ -7,25 +7,40 @@ namespace RCommon.Mediator { + /// + /// Default implementation of that delegates all operations to an . + /// + /// + /// This service acts as a thin wrapper around , providing a consistent + /// application-facing API while the adapter handles library-specific dispatch logic. + /// Registered as a scoped service by . + /// public class MediatorService : IMediatorService { private readonly IMediatorAdapter _mediatorAdapter; + /// + /// Initializes a new instance of . + /// + /// The mediator adapter that handles the actual dispatch to handlers. public MediatorService(IMediatorAdapter mediatorAdapter) { _mediatorAdapter = mediatorAdapter; } + /// public Task Publish(TNotification notification, CancellationToken cancellationToken = default) { return _mediatorAdapter.Publish(notification, cancellationToken); } + /// public async Task Send(TRequest request, CancellationToken cancellationToken = default) { await _mediatorAdapter.Send(request, cancellationToken); } + /// public async Task Send(TRequest request, CancellationToken cancellationToken = default) { return await _mediatorAdapter.Send(request, cancellationToken); diff --git a/Src/RCommon.Mediator/README.md b/Src/RCommon.Mediator/README.md index 79f2bfd6..cba4a67d 100644 --- a/Src/RCommon.Mediator/README.md +++ b/Src/RCommon.Mediator/README.md @@ -1,3 +1,102 @@ - # RCommon.Mediator +# RCommon.Mediator -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides a mediator abstraction layer for RCommon that decouples application code from specific mediator libraries, enabling request/response dispatch and notification publishing through a uniform API. + +## Features + +- **Library-agnostic mediator API** -- depend on `IMediatorService` instead of MediatR, Wolverine, or any other implementation +- **Request/response dispatch** -- send requests to a single handler with or without a return value +- **Notification publishing** -- broadcast notifications to all registered subscribers +- **Adapter pattern** -- swap mediator implementations by changing only the `IMediatorAdapter` registration +- **Subscriber contracts** -- `IAppRequest`, `IAppRequest`, and `IAppNotification` marker interfaces for message classification +- **Handler contracts** -- `IAppRequestHandler` and `IAppRequestHandler` for implementing handlers +- **Fluent builder API** -- integrates with the `AddRCommon()` builder pattern for clean DI configuration + +## Installation + +```shell +dotnet add package RCommon.Mediator +``` + +## Usage + +```csharp +using RCommon; +using RCommon.Mediator; +using RCommon.Mediator.Subscribers; + +// Configure the mediator in your DI setup using a concrete adapter (e.g., MediatR) +services.AddRCommon(config => +{ + config.WithMediator(mediator => + { + // Register handlers from your application assembly + mediator.AddHandlersFromAssemblyContainingType(); + }); +}); + +// Define a request and handler +public class CreateOrderRequest : IAppRequest +{ + public string ProductName { get; set; } + public int Quantity { get; set; } +} + +public class CreateOrderHandler : IAppRequestHandler +{ + public async Task HandleAsync(CreateOrderRequest request, + CancellationToken cancellationToken = default) + { + // Handle the request and return a result + return new OrderDto { Id = Guid.NewGuid(), ProductName = request.ProductName }; + } +} + +// Consume the mediator from your application layer +public class OrderController +{ + private readonly IMediatorService _mediator; + + public OrderController(IMediatorService mediator) + { + _mediator = mediator; + } + + public async Task CreateOrder(CreateOrderRequest request) + { + return await _mediator.Send(request); + } + + public async Task NotifyOrderCreated(OrderCreatedNotification notification) + { + await _mediator.Publish(notification); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IMediatorService` | Primary application-facing interface for sending requests and publishing notifications | +| `MediatorService` | Default implementation that delegates to an `IMediatorAdapter` | +| `IMediatorAdapter` | Adapter interface that bridges to a specific mediator library (e.g., MediatR) | +| `IMediatorBuilder` | Builder contract for configuring a mediator implementation within `AddRCommon()` | +| `IAppRequest` | Marker interface for requests dispatched to a single handler with no return value | +| `IAppRequest` | Marker interface for requests that return a response of type `TResponse` | +| `IAppNotification` | Marker interface for notifications broadcast to all registered subscribers | +| `IAppRequestHandler` | Handler contract for requests with no return value | +| `IAppRequestHandler` | Handler contract for requests that produce a typed response | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure +- [RCommon.Mediatr](https://www.nuget.org/packages/RCommon.Mediatr) - MediatR adapter implementation for `IMediatorAdapter` + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Mediator/Subscribers/IAppNotification.cs b/Src/RCommon.Mediator/Subscribers/IAppNotification.cs index af53b9f0..c228af9a 100644 --- a/Src/RCommon.Mediator/Subscribers/IAppNotification.cs +++ b/Src/RCommon.Mediator/Subscribers/IAppNotification.cs @@ -6,6 +6,14 @@ namespace RCommon.Mediator.Subscribers { + /// + /// Marker interface for notification messages that are broadcast to multiple handlers. + /// + /// + /// Implement this interface on DTOs that represent events or notifications which should + /// be dispatched to all registered subscribers via . + /// Unlike , notifications can have zero or more handlers. + /// public interface IAppNotification { } diff --git a/Src/RCommon.Mediator/Subscribers/IAppRequest.cs b/Src/RCommon.Mediator/Subscribers/IAppRequest.cs index 70a658d5..518abc6b 100644 --- a/Src/RCommon.Mediator/Subscribers/IAppRequest.cs +++ b/Src/RCommon.Mediator/Subscribers/IAppRequest.cs @@ -6,11 +6,26 @@ namespace RCommon.Mediator.Subscribers { + /// + /// Marker interface for request messages that are dispatched to a single handler with no return value. + /// + /// + /// Implement this interface on command or request DTOs that should be handled by exactly one + /// and do not produce a response. + /// public interface IAppRequest { } + /// + /// Marker interface for request messages that are dispatched to a single handler and return a response. + /// + /// The type of the response produced by the handler. + /// + /// Implement this interface on query or request DTOs that should be handled by exactly one + /// and produce a result of type . + /// public interface IAppRequest { diff --git a/Src/RCommon.Mediator/Subscribers/IAppRequestHandler.cs b/Src/RCommon.Mediator/Subscribers/IAppRequestHandler.cs index 8601d600..5241d3cb 100644 --- a/Src/RCommon.Mediator/Subscribers/IAppRequestHandler.cs +++ b/Src/RCommon.Mediator/Subscribers/IAppRequestHandler.cs @@ -6,13 +6,42 @@ namespace RCommon.Mediator.Subscribers { + /// + /// Defines a handler for a request that does not return a value. + /// + /// The type of the request to handle. + /// + /// Implement this interface to handle requests of type dispatched + /// via . Each request type should have exactly one handler. + /// public interface IAppRequestHandler { + /// + /// Handles the specified request asynchronously. + /// + /// The request to handle. + /// Optional token to cancel the operation. + /// A representing the asynchronous operation. public Task HandleAsync(TRequest request, CancellationToken cancellationToken = default); } + /// + /// Defines a handler for a request that returns a response. + /// + /// The type of the request to handle. + /// The type of the response to return. + /// + /// Implement this interface to handle requests of type dispatched + /// via . Each request type should have exactly one handler. + /// public interface IAppRequestHandler { + /// + /// Handles the specified request asynchronously and returns a response. + /// + /// The request to handle. + /// Optional token to cancel the operation. + /// A containing the handler's response. public Task HandleAsync(TRequest request, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs b/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs index 19d87c6a..ce12a325 100644 --- a/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs +++ b/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs @@ -10,13 +10,25 @@ namespace RCommon.Mediator.MediatR.Behaviors { + /// + /// MediatR pipeline behavior that logs the handling of fire-and-forget requests (no explicit response type). + /// Logs the command name and payload before and after the handler executes. + /// + /// The MediatR request type. Must implement . + /// The response type from the pipeline. public class LoggingRequestBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; + + /// + /// Initializes a new instance of . + /// + /// Logger for diagnostic output. public LoggingRequestBehavior(ILogger> logger) => _logger = logger; + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { _logger.LogInformation("----- Handling command {CommandName} ({@Command})", request.GetGenericTypeName(), request); @@ -27,13 +39,25 @@ public async Task Handle(TRequest request, RequestHandlerDelegate + /// MediatR pipeline behavior that logs the handling of requests that return a response. + /// Logs the command name and payload before and after the handler executes. + /// + /// The MediatR request type. Must implement . + /// The response type returned by the handler. public class LoggingRequestWithResponseBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; + + /// + /// Initializes a new instance of . + /// + /// Logger for diagnostic output. public LoggingRequestWithResponseBehavior(ILogger> logger) => _logger = logger; + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { _logger.LogInformation("----- Handling command {CommandName} ({@Command})", request.GetGenericTypeName(), request); diff --git a/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs b/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs index f025e026..962085bb 100644 --- a/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs +++ b/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs @@ -8,12 +8,23 @@ namespace RCommon.Mediator.MediatR.Behaviors { + /// + /// MediatR pipeline behavior that wraps fire-and-forget request handling in a transactional unit of work. + /// Creates a unit of work before the handler executes and commits it upon successful completion. + /// + /// The MediatR request type. Must implement . + /// The response type from the pipeline. public class UnitOfWorkRequestBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; private readonly IUnitOfWorkFactory _unitOfWorkScopeFactory; + /// + /// Initializes a new instance of . + /// + /// Factory for creating unit of work instances. + /// Logger for diagnostic output. public UnitOfWorkRequestBehavior(IUnitOfWorkFactory unitOfWorkScopeFactory, ILogger> logger) { @@ -21,6 +32,7 @@ public UnitOfWorkRequestBehavior(IUnitOfWorkFactory unitOfWorkScopeFactory, _logger = logger ?? throw new ArgumentException(nameof(ILogger)); } + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var response = default(TResponse); @@ -28,6 +40,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate + /// MediatR pipeline behavior that wraps request-with-response handling in a transactional unit of work. + /// Creates a unit of work before the handler executes and commits it upon successful completion. + /// + /// The MediatR request type. Must implement . + /// The response type returned by the handler. public class UnitOfWorkRequestWithResponseBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; private readonly IUnitOfWorkFactory _unitOfWorkScopeFactory; + /// + /// Initializes a new instance of . + /// + /// Factory for creating unit of work instances. + /// Logger for diagnostic output. public UnitOfWorkRequestWithResponseBehavior(IUnitOfWorkFactory unitOfWorkScopeFactory, ILogger> logger) { @@ -65,6 +89,7 @@ public UnitOfWorkRequestWithResponseBehavior(IUnitOfWorkFactory unitOfWorkScopeF _logger = logger ?? throw new ArgumentException(nameof(ILogger)); } + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var response = default(TResponse); @@ -72,6 +97,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate + /// MediatR pipeline behavior that validates requests implementing + /// before they reach the handler. Uses to perform validation, + /// throwing on failure to prevent invalid requests from being processed. + /// + /// The MediatR request type. Must implement . + /// The response type returned by the handler. public class ValidatorBehaviorForMediatR : IPipelineBehavior where TRequest : class, IRequest { private readonly IValidationService _validationService; private readonly ILogger> _logger; + /// + /// Initializes a new instance of . + /// + /// The validation service used to validate the request. + /// Logger for diagnostic output. public ValidatorBehaviorForMediatR(IValidationService validationService, ILogger> logger) { _validationService = validationService; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var typeName = request.GetGenericTypeName(); _logger.LogInformation("----- Validating command {CommandType}", typeName); + // Validate the request and throw on failure (second param = throwOnFailure) await _validationService.ValidateAsync(request, true, cancellationToken); return await next(); } } + /// + /// MediatR pipeline behavior that validates requests implementing + /// before they reach the handler. Uses to perform validation, + /// throwing on failure to prevent invalid requests from being processed. + /// + /// The RCommon application request type. Must implement . + /// The response type returned by the handler. public class ValidatorBehavior : IPipelineBehavior where TRequest : class, IAppRequest { private readonly IValidationService _validationService; private readonly ILogger> _logger; + /// + /// Initializes a new instance of . + /// + /// The validation service used to validate the request. + /// Logger for diagnostic output. public ValidatorBehavior(IValidationService validationService, ILogger> logger) { _validationService = validationService; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var typeName = request.GetGenericTypeName(); _logger.LogInformation("----- Validating command {CommandType}", typeName); + // Validate the request and throw on failure (second param = throwOnFailure) await _validationService.ValidateAsync(request, true, cancellationToken); return await next(); } diff --git a/Src/RCommon.Mediatr/IMediatRBuilder.cs b/Src/RCommon.Mediatr/IMediatRBuilder.cs index 2fd4fb4e..f18a5bba 100644 --- a/Src/RCommon.Mediatr/IMediatRBuilder.cs +++ b/Src/RCommon.Mediatr/IMediatRBuilder.cs @@ -2,9 +2,24 @@ namespace RCommon.Mediator.MediatR { + /// + /// Builder interface for configuring the MediatR mediator implementation within RCommon. + /// Extends with MediatR-specific configuration methods. + /// public interface IMediatRBuilder : IMediatorBuilder { + /// + /// Configures MediatR services using a configuration action delegate. + /// + /// An action to configure . + /// The builder instance for fluent chaining. IMediatRBuilder Configure(Action options); + + /// + /// Configures MediatR services using a pre-built configuration instance. + /// + /// The to apply. + /// The builder instance for fluent chaining. IMediatRBuilder Configure(MediatRServiceConfiguration options); } } diff --git a/Src/RCommon.Mediatr/IMediatREventHandlingBuilder.cs b/Src/RCommon.Mediatr/IMediatREventHandlingBuilder.cs index 0ed64089..c1021962 100644 --- a/Src/RCommon.Mediatr/IMediatREventHandlingBuilder.cs +++ b/Src/RCommon.Mediatr/IMediatREventHandlingBuilder.cs @@ -3,8 +3,12 @@ namespace RCommon.MediatR { + /// + /// Builder interface for configuring MediatR-based event handling within the RCommon framework. + /// Extends to provide MediatR-specific event subscription capabilities. + /// public interface IMediatREventHandlingBuilder : IEventHandlingBuilder { - + } } diff --git a/Src/RCommon.Mediatr/MediatRAdapter.cs b/Src/RCommon.Mediatr/MediatRAdapter.cs index 82bb20ee..953d0be5 100644 --- a/Src/RCommon.Mediatr/MediatRAdapter.cs +++ b/Src/RCommon.Mediatr/MediatRAdapter.cs @@ -17,6 +17,10 @@ public class MediatRAdapter : IMediatorAdapter { private readonly IMediator _mediator; + /// + /// Initializes a new instance of . + /// + /// The MediatR instance to delegate operations to. public MediatRAdapter(IMediator mediator) { _mediator = mediator; @@ -36,11 +40,26 @@ public Task Publish(TNotification notification, CancellationToken return _mediator.Publish(new MediatRNotification(notification), cancellationToken); } + /// + /// Wraps the request in a and sends it via MediatR for single-handler processing. + /// + /// The type of request to send. + /// The request payload. + /// Optional cancellation token. public async Task Send(TRequest request, CancellationToken cancellationToken = default) { await _mediator.Send(new MediatRRequest(request), cancellationToken); } + /// + /// Wraps the request in a and sends it via MediatR, + /// returning the response from the single handler. + /// + /// The type of request to send. + /// The expected response type. + /// The request payload. + /// Optional cancellation token. + /// The response produced by the request handler. public async Task Send(TRequest request, CancellationToken cancellationToken = default) { return await _mediator.Send(new MediatRRequest(request), cancellationToken); diff --git a/Src/RCommon.Mediatr/MediatRBuilder.cs b/Src/RCommon.Mediatr/MediatRBuilder.cs index 183b82b4..75972c0b 100644 --- a/Src/RCommon.Mediatr/MediatRBuilder.cs +++ b/Src/RCommon.Mediatr/MediatRBuilder.cs @@ -13,9 +13,17 @@ namespace RCommon.Mediator.MediatR { + /// + /// Default implementation of that registers MediatR services + /// and the as the within the DI container. + /// public class MediatRBuilder : IMediatRBuilder { + /// + /// Initializes a new instance of and registers core MediatR services. + /// + /// The whose service collection is used for dependency registration. public MediatRBuilder(IRCommonBuilder builder) { @@ -25,6 +33,10 @@ public MediatRBuilder(IRCommonBuilder builder) } + /// + /// Registers the and MediatR services from this assembly. + /// + /// The service collection to register services into. protected void RegisterServices(IServiceCollection services) { services.AddScoped(); @@ -35,18 +47,21 @@ protected void RegisterServices(IServiceCollection services) }); } + /// public IMediatRBuilder Configure(Action options) { Services.AddMediatR(options); return this; } + /// public IMediatRBuilder Configure(MediatRServiceConfiguration options) { Services.AddMediatR(options); return this; } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Mediatr/MediatRBuilderExtensions.cs b/Src/RCommon.Mediatr/MediatRBuilderExtensions.cs index 8ed1ba2b..bbd1bfa6 100644 --- a/Src/RCommon.Mediatr/MediatRBuilderExtensions.cs +++ b/Src/RCommon.Mediatr/MediatRBuilderExtensions.cs @@ -16,10 +16,20 @@ namespace RCommon { + /// + /// Extension methods for configuring notifications, requests, and pipeline behaviors + /// on an instance. + /// public static class MediatRBuilderExtensions { - + /// + /// Registers a notification subscriber and its corresponding MediatR . + /// Notifications are delivered to all registered handlers (fan-out). + /// + /// The notification event type. Must implement . + /// The subscriber implementation that handles the notification. + /// The MediatR builder. public static void AddNotification(this IMediatRBuilder builder) where T : class, IAppNotification where TEventHandler : class, ISubscriber @@ -30,6 +40,13 @@ public static void AddNotification(this IMediatRBuilder builde builder.Services.AddScoped>, MediatRNotificationHandler>>(); } + /// + /// Registers a request handler for a fire-and-forget request (no response). + /// Requests are handled by a single handler via MediatR's Send method. + /// + /// The request type. Must implement . + /// The handler that processes the request. + /// The MediatR builder. public static void AddRequest(this IMediatRBuilder builder) where TRequest : class, IAppRequest where TEventHandler : class, IAppRequestHandler @@ -41,6 +58,14 @@ public static void AddRequest(this IMediatRBuilder buil MediatRRequestHandler>>(); } + /// + /// Registers a request handler for a request that returns a response. + /// Requests are handled by a single handler via MediatR's Send method. + /// + /// The request type. Must implement . + /// The response type returned by the handler. + /// The handler that processes the request and produces the response. + /// The MediatR builder. public static void AddRequest(this IMediatRBuilder builder) where TRequest : class, IAppRequest where TResponse : class @@ -53,12 +78,24 @@ public static void AddRequest(this IMediatRB MediatRRequestHandler, TResponse>>(); } + /// + /// Adds logging pipeline behaviors to the MediatR request pipeline. + /// Registers both and + /// . + /// + /// The MediatR builder. public static void AddLoggingToRequestPipeline(this IMediatRBuilder builder) { builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingRequestBehavior<,>)); builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingRequestWithResponseBehavior<,>)); } + /// + /// Adds validation pipeline behaviors to the MediatR request pipeline. + /// Registers both and + /// , along with the . + /// + /// The MediatR builder. public static void AddValidationToRequestPipeline(this IMediatRBuilder builder) { builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidatorBehavior<,>)); @@ -66,6 +103,13 @@ public static void AddValidationToRequestPipeline(this IMediatRBuilder builder) builder.Services.AddScoped(); } + /// + /// Adds unit of work pipeline behaviors to the MediatR request pipeline. + /// Registers both and + /// so that + /// each request executes within a transactional unit of work. + /// + /// The MediatR builder. public static void AddUnitOfWorkToRequestPipeline(this IMediatRBuilder builder) { builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkRequestBehavior<,>)); diff --git a/Src/RCommon.Mediatr/MediatREventHandlingBuilder.cs b/Src/RCommon.Mediatr/MediatREventHandlingBuilder.cs index 9c813982..9d5ca600 100644 --- a/Src/RCommon.Mediatr/MediatREventHandlingBuilder.cs +++ b/Src/RCommon.Mediatr/MediatREventHandlingBuilder.cs @@ -13,19 +13,32 @@ namespace RCommon.MediatR { + /// + /// Default implementation of that registers MediatR event handling + /// services including the as the . + /// public class MediatREventHandlingBuilder : IMediatREventHandlingBuilder { + /// + /// Initializes a new instance of and registers core services. + /// + /// The whose service collection is used for dependency registration. public MediatREventHandlingBuilder(IRCommonBuilder builder) { this.RegisterServices(builder.Services); Services = builder.Services; } + /// + /// Registers the as the scoped implementation. + /// + /// The service collection to register services into. protected void RegisterServices(IServiceCollection services) - { + { services.AddScoped(); } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs b/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs index 1ed91690..caf931c8 100644 --- a/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs +++ b/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs @@ -16,14 +16,30 @@ namespace RCommon.MediatR { + /// + /// Extension methods for configuring MediatR-based event handling within the RCommon builder pipeline. + /// public static class MediatREventHandlingBuilderExtensions { + /// + /// Configures MediatR event handling with default settings and no custom actions. + /// + /// The implementation type. + /// The RCommon builder. + /// The for further chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder) where T : IMediatREventHandlingBuilder { return WithEventHandling(builder, x => { }, x=> { }); } + /// + /// Configures MediatR event handling with custom builder actions and default MediatR assembly registration. + /// + /// The implementation type. + /// The RCommon builder. + /// Configuration delegate for MediatR event handling. + /// The for further chaining. public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions) where T : IMediatREventHandlingBuilder { @@ -36,7 +52,16 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, return builder; } - public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions, + /// + /// Configures MediatR event handling with both custom event handling actions and custom MediatR service configuration. + /// Registers , wires up MediatR, and creates the event handling builder via reflection. + /// + /// The implementation type. + /// The RCommon builder. + /// Configuration delegate for the event handling builder. + /// Configuration delegate for . + /// The for further chaining. + public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions, Action mediatRActions) where T : IMediatREventHandlingBuilder { @@ -46,12 +71,19 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, builder.Services.AddMediatR(mediatRActions); // This will wire up common event handling - var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder }); + var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!; actions(eventHandlingConfig); return builder; } + /// + /// Registers an event subscriber and its corresponding MediatR notification handler for the event handling pipeline. + /// Also registers the event-to-producer subscription for correct routing. + /// + /// The event type. Must implement . + /// The subscriber implementation that handles the event. + /// The MediatR event handling builder. public static void AddSubscriber(this IMediatREventHandlingBuilder builder) where TEvent : class, ISerializableEvent where TEventHandler : class, ISubscriber diff --git a/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs b/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs index 4c6a2098..06df8241 100644 --- a/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs +++ b/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs @@ -13,6 +13,14 @@ namespace RCommon.MediatR.Producers { + /// + /// An implementation that publishes events to all notification handlers + /// using MediatR's publish (fan-out) semantics via . + /// + /// + /// Use this producer when you want an event to be delivered to all registered notification handlers. + /// For point-to-point delivery, use instead. + /// public class PublishWithMediatREventProducer : IEventProducer { private readonly IMediatorService _mediatorService; @@ -20,6 +28,13 @@ public class PublishWithMediatREventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The mediator service used to publish events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public PublishWithMediatREventProducer(IMediatorService mediatorService, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -29,6 +44,7 @@ public PublishWithMediatREventProducer(IMediatorService mediatorService, ILogger _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); } + /// public async Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : ISerializableEvent { @@ -36,12 +52,15 @@ public async Task ProduceEventAsync(TEvent @event, CancellationToken can { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(TEvent))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", new object[] { this.GetGenericTypeName(), typeof(TEvent).Name }); return; } + + // Create a scoped service context for the publish operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs b/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs index 5bd707a2..877fe43b 100644 --- a/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs +++ b/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs @@ -13,6 +13,14 @@ namespace RCommon.MediatR.Producers { + /// + /// An implementation that sends events to a single request handler + /// using MediatR's send (point-to-point) semantics via . + /// + /// + /// Use this producer for command-style messaging where only one handler should process the event. + /// For fan-out delivery to all handlers, use instead. + /// public class SendWithMediatREventProducer : IEventProducer { private readonly IMediatorService _mediatorService; @@ -20,6 +28,13 @@ public class SendWithMediatREventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The mediator service used to send events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public SendWithMediatREventProducer(IMediatorService mediatorService, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -29,6 +44,7 @@ public SendWithMediatREventProducer(IMediatorService mediatorService, ILogger public async Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : ISerializableEvent { @@ -36,12 +52,15 @@ public async Task ProduceEventAsync(TEvent @event, CancellationToken can { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(TEvent))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", new object[] { this.GetGenericTypeName(), typeof(TEvent).Name }); return; } + + // Create a scoped service context for the send operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.Mediatr/README.md b/Src/RCommon.Mediatr/README.md index 26afed5f..6fa0ff5b 100644 --- a/Src/RCommon.Mediatr/README.md +++ b/Src/RCommon.Mediatr/README.md @@ -1,3 +1,98 @@ - # RCommon.MediatR +# RCommon.MediatR -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Integrates [MediatR](https://github.com/jbogard/MediatR) with RCommon's event handling and mediator systems, providing in-process event production, notification/request handling, and pipeline behaviors while programming against RCommon's abstractions. + +## Features + +- Publish events to all notification handlers using MediatR's fan-out (publish) semantics +- Send events to a single request handler using MediatR's point-to-point (send) semantics +- Bridge MediatR notifications and requests to RCommon's `ISubscriber` and `IAppRequestHandler` abstractions +- Mediator adapter (`MediatRAdapter`) implementing `IMediatorAdapter` for request/response and notification patterns +- Built-in pipeline behaviors for logging, validation, and unit of work +- Support for both fire-and-forget requests and request/response patterns +- Event subscription routing ensures events are delivered only to their configured producers + +## Installation + +```shell +dotnet add package RCommon.MediatR +``` + +## Usage + +Configure MediatR for event handling: + +```csharp +using RCommon; +using RCommon.MediatR; + +builder.Services.AddRCommon() + .WithEventHandling(eventHandling => + { + // Register event subscribers + eventHandling.AddSubscriber(); + }); +``` + +Configure MediatR as the mediator with requests, notifications, and pipeline behaviors: + +```csharp +using RCommon; +using RCommon.Mediator.MediatR; + +builder.Services.AddRCommon() + .WithMediator(mediator => + { + // Register notifications (fan-out to all handlers) + mediator.AddNotification(); + + // Register requests (single handler, fire-and-forget) + mediator.AddRequest(); + + // Register requests with responses + mediator.AddRequest(); + + // Add pipeline behaviors + mediator.AddLoggingToRequestPipeline(); + mediator.AddValidationToRequestPipeline(); + mediator.AddUnitOfWorkToRequestPipeline(); + + // Custom MediatR configuration + mediator.Configure(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + }); + }); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `PublishWithMediatREventProducer` | `IEventProducer` that publishes events to all handlers via MediatR publish (fan-out) | +| `SendWithMediatREventProducer` | `IEventProducer` that sends events to a single handler via MediatR send (point-to-point) | +| `MediatRAdapter` | `IMediatorAdapter` implementation that wraps MediatR's `IMediator` for send/publish operations | +| `MediatREventHandler` | Bridges MediatR `INotificationHandler` to RCommon's `ISubscriber` for event handling | +| `MediatRNotificationHandler` | Bridges MediatR notifications to RCommon's `ISubscriber` for app notifications | +| `MediatRRequestHandler` | Bridges MediatR requests to RCommon's `IAppRequestHandler` | +| `MediatRRequestHandler` | Bridges MediatR requests to RCommon's `IAppRequestHandler` for request/response | +| `LoggingRequestBehavior` | Pipeline behavior that logs command handling | +| `ValidatorBehavior` | Pipeline behavior that validates requests before handling | +| `UnitOfWorkRequestBehavior` | Pipeline behavior that wraps handlers in a transactional unit of work | +| `MediatREventHandlingBuilder` | Builder for configuring MediatR-based event handling | +| `MediatRBuilder` | Builder for configuring MediatR as the mediator implementation | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions including `IEventProducer` and `ISubscriber` +- [RCommon.Mediator](https://www.nuget.org/packages/RCommon.Mediator) - Mediator abstractions (`IMediatorService`, `IMediatorAdapter`) +- [RCommon.MassTransit](https://www.nuget.org/packages/RCommon.MassTransit) - MassTransit integration for distributed messaging +- [RCommon.Wolverine](https://www.nuget.org/packages/RCommon.Wolverine) - Wolverine integration for distributed messaging + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Mediatr/Subscribers/IMediatRNotification.cs b/Src/RCommon.Mediatr/Subscribers/IMediatRNotification.cs index 1bec7aa5..f37fb444 100644 --- a/Src/RCommon.Mediatr/Subscribers/IMediatRNotification.cs +++ b/Src/RCommon.Mediatr/Subscribers/IMediatRNotification.cs @@ -3,13 +3,24 @@ namespace RCommon.MediatR.Subscribers { + /// + /// Non-generic marker interface for MediatR notifications within the RCommon framework. + /// Extends to participate in the MediatR pipeline. + /// public interface IMediatRNotification: INotification { } + /// + /// Generic interface for MediatR notifications that wrap an underlying event payload. + /// + /// The type of the wrapped event payload. public interface IMediatRNotification : IMediatRNotification { + /// + /// Gets or sets the underlying notification event payload. + /// TEvent Notification { get; set; } } } diff --git a/Src/RCommon.Mediatr/Subscribers/IMediatRRequest.cs b/Src/RCommon.Mediatr/Subscribers/IMediatRRequest.cs index 964c9a48..d316a8bf 100644 --- a/Src/RCommon.Mediatr/Subscribers/IMediatRRequest.cs +++ b/Src/RCommon.Mediatr/Subscribers/IMediatRRequest.cs @@ -2,19 +2,35 @@ namespace RCommon.MediatR.Subscribers { - + /// + /// Non-generic marker interface for MediatR requests within the RCommon framework. + /// Extends for fire-and-forget request semantics. + /// public interface IMediatRRequest : IRequest { } + /// + /// Generic interface for MediatR requests that return a response. + /// Extends both and . + /// + /// The response type returned by the request handler. public interface IMediatRRequest : IRequest, IMediatRRequest { } + /// + /// Generic interface for MediatR requests that wrap an underlying request payload and return a response. + /// + /// The type of the wrapped request payload. + /// The response type returned by the request handler. public interface IMediatRRequest : IMediatRRequest { + /// + /// Gets the underlying request payload. + /// TRequest Request { get; } } } \ No newline at end of file diff --git a/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs index 6829a590..975fe30d 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs @@ -12,27 +12,49 @@ namespace RCommon.MediatR.Subscribers { + /// + /// MediatR notification handler that bridges notifications + /// to RCommon's abstraction for serializable event handling. + /// Resolves the subscriber from the DI container and delegates event handling. + /// + /// The serializable event type. Must implement . + /// The MediatR notification wrapper type. + /// + /// This differs from in that it handles + /// types used in the event handling pipeline, rather than + /// types used in the mediator pipeline. + /// public class MediatREventHandler : INotificationHandler where TEvent : class, ISerializableEvent where TNotification : MediatRNotification { private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The service provider used to resolve at runtime. public MediatREventHandler(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } + /// + /// Handles the MediatR notification by resolving the RCommon subscriber and delegating the event. + /// + /// The MediatR notification containing the wrapped serializable event. + /// Cancellation token. + /// Thrown when the subscriber cannot be resolved from the service provider. public async Task Handle(TNotification notification, CancellationToken cancellationToken) { // Resolve the actual event handler that we want to execute - var subscriber = (ISubscriber)_serviceProvider.GetService(typeof(ISubscriber)); + var subscriber = (ISubscriber?)_serviceProvider.GetService(typeof(ISubscriber)); Guard.Against(subscriber == null, "ISubscriber of type: " + typeof(TEvent).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - await subscriber.HandleAsync(notification.Notification); + await subscriber!.HandleAsync(notification.Notification); } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRNotification.cs b/Src/RCommon.Mediatr/Subscribers/MediatRNotification.cs index 7804065f..2bb99528 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRNotification.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRNotification.cs @@ -7,14 +7,24 @@ namespace RCommon.MediatR.Subscribers { + /// + /// Wrapper class that adapts an event of type into an + /// so it can be published through the MediatR notification pipeline. + /// + /// The type of the event being wrapped. public class MediatRNotification : IMediatRNotification { + /// + /// Initializes a new instance of with the specified event payload. + /// + /// The event payload to wrap. public MediatRNotification(TEvent notification) { Notification = notification; } + /// public TEvent Notification { get; set; } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs index a5fb4d8e..58460ebf 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs @@ -13,27 +13,44 @@ namespace RCommon.MediatR.Subscribers { + /// + /// MediatR notification handler that bridges notifications + /// to RCommon's abstraction for application notifications. + /// Resolves the subscriber from the DI container and delegates event handling. + /// + /// The application notification type. Must implement . + /// The MediatR notification wrapper type. public class MediatRNotificationHandler : INotificationHandler where T : class, IAppNotification where TNotification : MediatRNotification { private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The service provider used to resolve at runtime. public MediatRNotificationHandler(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } + /// + /// Handles the MediatR notification by resolving the RCommon subscriber and delegating the event. + /// + /// The MediatR notification containing the wrapped event. + /// Cancellation token. + /// Thrown when the subscriber cannot be resolved from the service provider. public async Task Handle(TNotification notification, CancellationToken cancellationToken) { // Resolve the actual event handler that we want to execute - var subscriber = (ISubscriber) _serviceProvider.GetService(typeof(ISubscriber)); - - Guard.Against(subscriber == null, + var subscriber = (ISubscriber?) _serviceProvider.GetService(typeof(ISubscriber)); + + Guard.Against(subscriber == null, "ISubscriber of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); - + // Handle the event using the event handler we resolved - await subscriber.HandleAsync(notification.Notification); + await subscriber!.HandleAsync(notification.Notification); } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRRequest.cs b/Src/RCommon.Mediatr/Subscribers/MediatRRequest.cs index 53b2143a..469c8a23 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRRequest.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRRequest.cs @@ -8,25 +8,46 @@ namespace RCommon.MediatR.Subscribers { + /// + /// Wrapper class that adapts a request of type into an + /// for fire-and-forget processing through the MediatR pipeline. + /// + /// The type of the wrapped request payload. public class MediatRRequest : IMediatRRequest { + /// + /// Initializes a new instance of with the specified request payload. + /// + /// The request payload to wrap. public MediatRRequest(TRequest request) { Request = request; } + /// public TRequest Request { get; } } + /// + /// Wrapper class that adapts a request of type into an + /// for request/response processing through the MediatR pipeline. + /// + /// The type of the wrapped request payload. + /// The response type returned by the handler. public class MediatRRequest : IMediatRRequest { + /// + /// Initializes a new instance of with the specified request payload. + /// + /// The request payload to wrap. public MediatRRequest(TRequest request) { Request = request; } + /// public TRequest Request { get; } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs index 7dbd3349..e95f0876 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs @@ -13,51 +13,87 @@ namespace RCommon.MediatR.Subscribers { + /// + /// MediatR request handler that bridges requests + /// to RCommon's abstraction for fire-and-forget processing. + /// Resolves the handler from the DI container and delegates request handling. + /// + /// The application request type. Must implement . + /// The MediatR request wrapper type. public class MediatRRequestHandler : IRequestHandler where T : class, IAppRequest where TRequest : MediatRRequest { private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The service provider used to resolve at runtime. public MediatRRequestHandler(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } + /// + /// Handles the MediatR request by resolving the RCommon request handler and delegating the request. + /// + /// The MediatR request containing the wrapped application request. + /// Cancellation token. + /// Thrown when the handler cannot be resolved from the service provider. public async Task Handle(TRequest request, CancellationToken cancellationToken) { // Resolve the actual event handler that we want to execute - var handler = (IAppRequestHandler)_serviceProvider.GetService(typeof(IAppRequestHandler)); + var handler = (IAppRequestHandler?)_serviceProvider.GetService(typeof(IAppRequestHandler)); Guard.Against(handler == null, "IAppRequestHandler of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - await handler.HandleAsync(request.Request); + await handler!.HandleAsync(request.Request); } } + /// + /// MediatR request handler that bridges requests + /// to RCommon's abstraction for request/response processing. + /// Resolves the handler from the DI container and delegates request handling. + /// + /// The application request type. Must implement . + /// The MediatR request wrapper type. + /// The response type returned by the handler. public class MediatRRequestHandler : IRequestHandler where T : class, IAppRequest where TRequest : MediatRRequest { private readonly IServiceProvider _serviceProvider; + /// + /// Initializes a new instance of . + /// + /// The service provider used to resolve at runtime. public MediatRRequestHandler(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } + /// + /// Handles the MediatR request by resolving the RCommon request handler and delegating the request. + /// + /// The MediatR request containing the wrapped application request. + /// Cancellation token. + /// The response produced by the resolved application request handler. + /// Thrown when the handler cannot be resolved from the service provider. public async Task Handle(TRequest request, CancellationToken cancellationToken) { // Resolve the actual event handler that we want to execute - var handler = (IAppRequestHandler)_serviceProvider.GetService(typeof(IAppRequestHandler)); + var handler = (IAppRequestHandler?)_serviceProvider.GetService(typeof(IAppRequestHandler)); Guard.Against(handler == null, "IAppRequestHandler of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - return await handler.HandleAsync(request.Request); + return await handler!.HandleAsync(request.Request); } } } diff --git a/Src/RCommon.MemoryCache/DistributedMemoryCacheBuilder.cs b/Src/RCommon.MemoryCache/DistributedMemoryCacheBuilder.cs index d691c874..c438c011 100644 --- a/Src/RCommon.MemoryCache/DistributedMemoryCacheBuilder.cs +++ b/Src/RCommon.MemoryCache/DistributedMemoryCacheBuilder.cs @@ -7,19 +7,38 @@ namespace RCommon.MemoryCache { + /// + /// Builder for configuring distributed memory caching using the Microsoft + /// abstraction + /// backed by an in-memory store. + /// + /// + /// This is the concrete builder activated by + /// + /// when DistributedMemoryCacheBuilder is specified as the type parameter. + /// public class DistributedMemoryCacheBuilder : IDistributedMemoryCachingBuilder { + /// + /// Initializes a new instance of the class. + /// + /// The RCommon builder whose is used for service registration. public DistributedMemoryCacheBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers any default services required by the distributed memory cache builder. + /// + /// The service collection to register services into. protected void RegisterServices(IServiceCollection services) { } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.MemoryCache/DistributedMemoryCacheService.cs b/Src/RCommon.MemoryCache/DistributedMemoryCacheService.cs index 3f5f2f68..376aac7a 100644 --- a/Src/RCommon.MemoryCache/DistributedMemoryCacheService.cs +++ b/Src/RCommon.MemoryCache/DistributedMemoryCacheService.cs @@ -10,47 +10,67 @@ namespace RCommon.MemoryCache { /// - /// Just a proxy for Distributed memory caching implemented through caching abstractions + /// A proxy for distributed memory caching implemented through the + /// abstraction. /// - /// This gives us a uniform way for getting/setting cache no matter the caching strategy + /// + /// This gives a uniform way for getting/setting cache no matter the caching strategy. + /// Data is serialized to JSON via before being stored + /// in the distributed cache, and deserialized on retrieval. + /// public class DistributedMemoryCacheService : ICacheService { private readonly IDistributedCache _distributedCache; private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The underlying distributed cache implementation. + /// The JSON serializer used to serialize/deserialize cached values. public DistributedMemoryCacheService(IDistributedCache distributedCache, IJsonSerializer jsonSerializer) { _distributedCache = distributedCache; _jsonSerializer = jsonSerializer; } + /// public TData GetOrCreate(object key, Func data) { - var json = _distributedCache.GetString(key.ToString()); + var cacheKey = key.ToString()!; + var json = _distributedCache.GetString(cacheKey); if (json == null) { - _distributedCache.SetString(key.ToString(), _jsonSerializer.Serialize(data())); - return data(); + // Cache miss: invoke the factory, serialize and store the result, then return it + var result = data(); + _distributedCache.SetString(cacheKey, _jsonSerializer.Serialize(result!)); + return result; } else { - return _jsonSerializer.Deserialize(json); + // Cache hit: deserialize the stored JSON back into the requested type + return _jsonSerializer.Deserialize(json)!; } } + /// public async Task GetOrCreateAsync(object key, Func data) { - var json = await _distributedCache.GetStringAsync(key.ToString()).ConfigureAwait(false); + var cacheKey = key.ToString()!; + var json = await _distributedCache.GetStringAsync(cacheKey).ConfigureAwait(false); if (json == null) { - await _distributedCache.SetStringAsync(key.ToString(), _jsonSerializer.Serialize(data())).ConfigureAwait(false); - return data(); + // Cache miss: invoke the factory, serialize and store the result asynchronously + var result = data(); + await _distributedCache.SetStringAsync(cacheKey, _jsonSerializer.Serialize(result!)).ConfigureAwait(false); + return result; } else { - return _jsonSerializer.Deserialize(json); + // Cache hit: deserialize the stored JSON back into the requested type + return _jsonSerializer.Deserialize(json)!; } } } diff --git a/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilder.cs b/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilder.cs index a500c5a1..949b5f3e 100644 --- a/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilder.cs +++ b/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilder.cs @@ -7,6 +7,13 @@ namespace RCommon.MemoryCache { + /// + /// Marker interface for configuring distributed caching that is backed by an in-memory store. + /// + /// + /// Extends to allow memory-specific distributed + /// cache configuration via extension methods in . + /// public interface IDistributedMemoryCachingBuilder : IDistributedCachingBuilder { } diff --git a/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilderExtensions.cs b/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilderExtensions.cs index d4dcda72..6360daba 100644 --- a/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilderExtensions.cs +++ b/Src/RCommon.MemoryCache/IDistributedMemoryCachingBuilderExtensions.cs @@ -10,8 +10,18 @@ namespace RCommon.MemoryCache { + /// + /// Extension methods for that configure + /// distributed memory cache options and expression caching. + /// public static class IDistributedMemoryCachingBuilderExtensions { + /// + /// Configures the underlying for the distributed memory cache. + /// + /// The distributed memory caching builder. + /// A delegate to configure . + /// The same for chaining. public static IDistributedMemoryCachingBuilder Configure(this IDistributedMemoryCachingBuilder builder, Action actions) { builder.Services.AddDistributedMemoryCache(actions); @@ -19,7 +29,7 @@ public static IDistributedMemoryCachingBuilder Configure(this IDistributedMemory } /// - /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily + /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily /// to compile expressions and lambdas /// /// Builder @@ -34,22 +44,28 @@ public static IDistributedMemoryCachingBuilder CacheDynamicallyCompiledExpressio builder.Services.TryAddTransient, CommonFactory>(); ConfigureCachingOptions(builder); - // Add Caching Factory + // Add Caching Factory — resolves the correct ICacheService based on the ExpressionCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case ExpressionCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } }); return builder; } - private static void ConfigureCachingOptions(IDistributedMemoryCachingBuilder builder, Action configure = null) + /// + /// Configures with default or custom settings, enabling caching + /// and expression caching flags. + /// + /// The distributed memory caching builder. + /// An optional delegate to customize . When null, defaults are applied. + private static void ConfigureCachingOptions(IDistributedMemoryCachingBuilder builder, Action? configure = null) { if (configure == null) diff --git a/Src/RCommon.MemoryCache/IInMemoryCachingBuilder.cs b/Src/RCommon.MemoryCache/IInMemoryCachingBuilder.cs index 50aea9fd..bd357858 100644 --- a/Src/RCommon.MemoryCache/IInMemoryCachingBuilder.cs +++ b/Src/RCommon.MemoryCache/IInMemoryCachingBuilder.cs @@ -7,6 +7,13 @@ namespace RCommon.MemoryCache { + /// + /// Marker interface for configuring in-memory caching backed by . + /// + /// + /// Extends to allow in-memory-specific cache + /// configuration via extension methods in . + /// public interface IInMemoryCachingBuilder : IMemoryCachingBuilder { } diff --git a/Src/RCommon.MemoryCache/IInMemoryCachingBuilderExtensions.cs b/Src/RCommon.MemoryCache/IInMemoryCachingBuilderExtensions.cs index f7ab9a40..f758328f 100644 --- a/Src/RCommon.MemoryCache/IInMemoryCachingBuilderExtensions.cs +++ b/Src/RCommon.MemoryCache/IInMemoryCachingBuilderExtensions.cs @@ -10,8 +10,18 @@ namespace RCommon.MemoryCache { + /// + /// Extension methods for that configure + /// in-memory cache options and expression caching. + /// public static class IInMemoryCachingBuilderExtensions { + /// + /// Configures the underlying for the in-memory cache. + /// + /// The in-memory caching builder. + /// A delegate to configure . + /// The same for chaining. public static IInMemoryCachingBuilder Configure(this IInMemoryCachingBuilder builder, Action actions) { builder.Services.AddMemoryCache(actions); @@ -19,7 +29,7 @@ public static IInMemoryCachingBuilder Configure(this IInMemoryCachingBuilder bui } /// - /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily + /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily /// to compile expressions and lambdas /// /// Builder @@ -34,22 +44,28 @@ public static IInMemoryCachingBuilder CacheDynamicallyCompiledExpressions(this I builder.Services.TryAddTransient, CommonFactory>(); ConfigureCachingOptions(builder); - // Add Caching Factory + // Add Caching Factory — resolves the correct ICacheService based on the ExpressionCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case ExpressionCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } }); return builder; } - private static void ConfigureCachingOptions(IInMemoryCachingBuilder builder, Action configure = null) + /// + /// Configures with default or custom settings, enabling caching + /// and expression caching flags. + /// + /// The in-memory caching builder. + /// An optional delegate to customize . When null, defaults are applied. + private static void ConfigureCachingOptions(IInMemoryCachingBuilder builder, Action? configure = null) { if (configure == null) diff --git a/Src/RCommon.MemoryCache/InMemoryCacheService.cs b/Src/RCommon.MemoryCache/InMemoryCacheService.cs index cb0d19f7..88789d7c 100644 --- a/Src/RCommon.MemoryCache/InMemoryCacheService.cs +++ b/Src/RCommon.MemoryCache/InMemoryCacheService.cs @@ -9,32 +9,42 @@ namespace RCommon.MemoryCache { /// - /// Just a proxy for memory caching implemented through caching abstractions + /// A proxy for in-process memory caching implemented through the + /// abstraction. /// - /// This gives us a uniform way for getting/setting cache no matter the caching strategy + /// + /// This gives a uniform way for getting/setting cache no matter the caching strategy. + /// Delegates directly to and its async counterpart. + /// public class InMemoryCacheService : ICacheService { private readonly IMemoryCache _memoryCache; + /// + /// Initializes a new instance of the class. + /// + /// The underlying implementation. public InMemoryCacheService(IMemoryCache memoryCache) { _memoryCache = memoryCache; } + /// public TData GetOrCreate(object key, Func data) { return _memoryCache.GetOrCreate(key, cacheEntry => { return data(); - }); + })!; } + /// public async Task GetOrCreateAsync(object key, Func data) { - return await _memoryCache.GetOrCreateAsync(key, async cacheEntry => + return (await _memoryCache.GetOrCreateAsync(key, async cacheEntry => { return await Task.FromResult(data()); - }).ConfigureAwait(false); + }).ConfigureAwait(false))!; } } } diff --git a/Src/RCommon.MemoryCache/InMemoryCachingBuilder.cs b/Src/RCommon.MemoryCache/InMemoryCachingBuilder.cs index 9053b2d2..1fcc78a9 100644 --- a/Src/RCommon.MemoryCache/InMemoryCachingBuilder.cs +++ b/Src/RCommon.MemoryCache/InMemoryCachingBuilder.cs @@ -8,19 +8,37 @@ namespace RCommon.MemoryCache { + /// + /// Builder for configuring in-memory caching using the Microsoft + /// abstraction. + /// + /// + /// This is the concrete builder activated by + /// + /// when InMemoryCachingBuilder is specified as the type parameter. + /// public class InMemoryCachingBuilder : IInMemoryCachingBuilder { + /// + /// Initializes a new instance of the class. + /// + /// The RCommon builder whose is used for service registration. public InMemoryCachingBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers any default services required by the in-memory cache builder. + /// + /// The service collection to register services into. protected void RegisterServices(IServiceCollection services) { - + } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.MemoryCache/RCommon.MemoryCache.csproj b/Src/RCommon.MemoryCache/RCommon.MemoryCache.csproj index b92495c5..d917779c 100644 --- a/Src/RCommon.MemoryCache/RCommon.MemoryCache.csproj +++ b/Src/RCommon.MemoryCache/RCommon.MemoryCache.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.MemoryCache https://rcommon.com diff --git a/Src/RCommon.MemoryCache/README.md b/Src/RCommon.MemoryCache/README.md index d38fe775..358751e7 100644 --- a/Src/RCommon.MemoryCache/README.md +++ b/Src/RCommon.MemoryCache/README.md @@ -1,3 +1,66 @@ - # RCommon.MemoryCache +# RCommon.MemoryCache -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides two in-process memory caching implementations of `ICacheService`: one backed by `IMemoryCache` and another backed by `IDistributedCache` (in-memory distributed cache), with fluent builder extensions for DI configuration. + +## Features + +- `InMemoryCacheService` -- delegates to Microsoft's `IMemoryCache` for fast in-process caching with `GetOrCreate`/`GetOrCreateAsync` +- `DistributedMemoryCacheService` -- delegates to `IDistributedCache` (in-memory distributed store) with automatic JSON serialization via `IJsonSerializer` +- `InMemoryCachingBuilder` and `DistributedMemoryCacheBuilder` for plugging into the `AddRCommon()` builder pipeline +- `Configure()` extension to customize `MemoryCacheOptions` or `MemoryDistributedCacheOptions` +- `CacheDynamicallyCompiledExpressions()` extension to enable expression caching, which improves performance in areas of RCommon that compile expressions and lambdas at runtime + +## Installation + +```shell +dotnet add package RCommon.MemoryCache +``` + +## Usage + +```csharp +using RCommon; +using RCommon.MemoryCache; + +services.AddRCommon(builder => +{ + // Option 1: In-process IMemoryCache + builder.WithMemoryCaching(cache => + { + cache.Configure(options => options.SizeLimit = 1024); + cache.CacheDynamicallyCompiledExpressions(); + }); + + // Option 2: Distributed memory cache (IDistributedCache backed by memory) + builder.WithDistributedCaching(cache => + { + cache.Configure(options => options.SizeLimit = 2048); + cache.CacheDynamicallyCompiledExpressions(); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `InMemoryCacheService` | `ICacheService` implementation backed by `IMemoryCache` | +| `DistributedMemoryCacheService` | `ICacheService` implementation backed by `IDistributedCache` with JSON serialization | +| `InMemoryCachingBuilder` | Concrete builder for configuring in-process memory caching | +| `DistributedMemoryCacheBuilder` | Concrete builder for configuring distributed memory caching | +| `IInMemoryCachingBuilder` | Builder interface extending `IMemoryCachingBuilder` | +| `IDistributedMemoryCachingBuilder` | Builder interface extending `IDistributedCachingBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Caching](https://www.nuget.org/packages/RCommon.Caching) - Core caching abstractions (`ICacheService`, `CacheKey`, builder contracts) +- [RCommon.RedisCache](https://www.nuget.org/packages/RCommon.RedisCache) - Redis-backed distributed cache implementation +- [RCommon.Persistence.Caching.MemoryCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.MemoryCache) - Wires memory caching into the persistence caching repository decorators + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Models/Commands/CommandResult.cs b/Src/RCommon.Models/Commands/CommandResult.cs index 2a9aa856..72c70e03 100644 --- a/Src/RCommon.Models/Commands/CommandResult.cs +++ b/Src/RCommon.Models/Commands/CommandResult.cs @@ -8,13 +8,27 @@ namespace RCommon.Models.Commands { + /// + /// Default implementation of that wraps + /// an execution result returned from a command handler. + /// + /// The type of conveying the command outcome. + /// + /// This is a record type decorated with to support + /// serialization across service boundaries. + /// [DataContract] public record CommandResult : ICommandResult where TExecutionResult : IExecutionResult { + /// [DataMember] public TExecutionResult Result { get; } + /// + /// Initializes a new instance of the record. + /// + /// The execution result representing the outcome of the command. public CommandResult(TExecutionResult result) { Result = result; diff --git a/Src/RCommon.Models/Commands/ICommand.cs b/Src/RCommon.Models/Commands/ICommand.cs index fe14835e..d80283b1 100644 --- a/Src/RCommon.Models/Commands/ICommand.cs +++ b/Src/RCommon.Models/Commands/ICommand.cs @@ -7,10 +7,24 @@ namespace RCommon.Models.Commands { + /// + /// Marker interface representing a command in the CQRS pattern. + /// Commands encapsulate intent to change the system state and are typically + /// dispatched to a single handler for processing. + /// + /// public interface ICommand : IModel { } + /// + /// Generic command interface that specifies the expected execution result type. + /// Use this interface when a command handler should return a typed result indicating + /// the outcome of the operation. + /// + /// The type of returned by the command handler. + /// + /// public interface ICommand : ICommand where TResult : IExecutionResult { diff --git a/Src/RCommon.Models/Commands/ICommandResult.cs b/Src/RCommon.Models/Commands/ICommandResult.cs index c54bbf93..3d981a3f 100644 --- a/Src/RCommon.Models/Commands/ICommandResult.cs +++ b/Src/RCommon.Models/Commands/ICommandResult.cs @@ -2,9 +2,19 @@ namespace RCommon.Models.Commands { + /// + /// Represents the result of executing a command, wrapping an + /// to indicate whether the command succeeded or failed. + /// + /// The type of that conveys the execution outcome. + /// + /// public interface ICommandResult : IModel where TExecutionResult : IExecutionResult { + /// + /// Gets the execution result indicating the outcome of the command. + /// TExecutionResult Result { get; } } } diff --git a/Src/RCommon.Models/Events/IAsyncEvent.cs b/Src/RCommon.Models/Events/IAsyncEvent.cs index bf1c9d94..79ac9ecd 100644 --- a/Src/RCommon.Models/Events/IAsyncEvent.cs +++ b/Src/RCommon.Models/Events/IAsyncEvent.cs @@ -6,6 +6,16 @@ namespace RCommon.Models.Events { + /// + /// Marker interface for events that are dispatched and handled asynchronously. + /// Async events are typically published to a message bus or queue for eventual processing. + /// + /// + /// Extends because asynchronous delivery mechanisms + /// (e.g., message brokers) generally require events to be serializable. + /// + /// + /// public interface IAsyncEvent : ISerializableEvent { } diff --git a/Src/RCommon.Models/Events/ISerializableEvent.cs b/Src/RCommon.Models/Events/ISerializableEvent.cs index 998dc945..8408b44e 100644 --- a/Src/RCommon.Models/Events/ISerializableEvent.cs +++ b/Src/RCommon.Models/Events/ISerializableEvent.cs @@ -6,6 +6,12 @@ namespace RCommon.Models.Events { + /// + /// Marker interface indicating that an event can be serialized for transport + /// across process boundaries (e.g., message queues, event stores, distributed systems). + /// + /// + /// public interface ISerializableEvent { } diff --git a/Src/RCommon.Models/Events/ISyncEvent.cs b/Src/RCommon.Models/Events/ISyncEvent.cs index 782c7964..f94dff3b 100644 --- a/Src/RCommon.Models/Events/ISyncEvent.cs +++ b/Src/RCommon.Models/Events/ISyncEvent.cs @@ -6,6 +6,16 @@ namespace RCommon.Models.Events { + /// + /// Marker interface for events that are dispatched and handled synchronously + /// within the same process boundary. + /// + /// + /// Extends to allow synchronous events to + /// optionally participate in serialization scenarios (e.g., logging, auditing). + /// + /// + /// public interface ISyncEvent : ISerializableEvent { } diff --git a/Src/RCommon.Models/ExecutionResults/ExecutionResult.cs b/Src/RCommon.Models/ExecutionResults/ExecutionResult.cs index d3bfef80..aec9b6ec 100644 --- a/Src/RCommon.Models/ExecutionResults/ExecutionResult.cs +++ b/Src/RCommon.Models/ExecutionResults/ExecutionResult.cs @@ -27,20 +27,58 @@ namespace RCommon.Models.ExecutionResults { + /// + /// Abstract base record for execution results, providing factory methods + /// to create and instances. + /// + /// + /// Cached singleton instances are used for and the parameterless + /// to avoid unnecessary allocations for common result types. + /// + /// [DataContract] public abstract record ExecutionResult : IExecutionResult { + // Cached singleton for the common success case to avoid repeated allocations. private static readonly IExecutionResult SuccessResult = new SuccessExecutionResult(); + + // Cached singleton for a generic failure with no error messages. private static readonly IExecutionResult FailedResult = new FailedExecutionResult(Enumerable.Empty()); + /// + /// Returns a cached instance. + /// + /// An representing a successful operation. public static IExecutionResult Success() => SuccessResult; + + /// + /// Returns a cached instance with no error messages. + /// + /// An representing a failed operation. public static IExecutionResult Failed() => FailedResult; + + /// + /// Creates a new with the specified error messages. + /// + /// A collection of error messages describing the failure. + /// An representing a failed operation with error details. public static IExecutionResult Failed(IEnumerable errors) => new FailedExecutionResult(errors); + + /// + /// Creates a new with the specified error messages. + /// + /// One or more error messages describing the failure. + /// An representing a failed operation with error details. public static IExecutionResult Failed(params string[] errors) => new FailedExecutionResult(errors); + /// [DataMember] public abstract bool IsSuccess { get; } + /// + /// Returns a string representation of the execution result, including the success status. + /// + /// A string in the format "ExecutionResult - IsSuccess:{value}". public override string ToString() { return $"ExecutionResult - IsSuccess:{IsSuccess}"; diff --git a/Src/RCommon.Models/ExecutionResults/FailedExecutionResult.cs b/Src/RCommon.Models/ExecutionResults/FailedExecutionResult.cs index 106f4815..35d13db5 100644 --- a/Src/RCommon.Models/ExecutionResults/FailedExecutionResult.cs +++ b/Src/RCommon.Models/ExecutionResults/FailedExecutionResult.cs @@ -27,20 +27,41 @@ namespace RCommon.Models.ExecutionResults { + /// + /// Represents a failed execution result, optionally containing one or more error messages + /// that describe the reason(s) for failure. + /// + /// + /// [DataContract] public record FailedExecutionResult : ExecutionResult { + /// + /// Gets the collection of error messages associated with the failure. + /// public IReadOnlyCollection Errors { get; } + /// + /// Initializes a new instance of the record. + /// + /// + /// A collection of error messages describing the failure. If null, an empty collection is used. + /// public FailedExecutionResult( IEnumerable errors) { + // Materialize to a list and guard against null to ensure Errors is never null. Errors = (errors ?? Enumerable.Empty()).ToList(); } + /// [DataMember] public override bool IsSuccess { get; } = false; + /// + /// Returns a string describing the failure, including error messages if any are present. + /// + /// A human-readable description of the failure and its associated errors. public override string ToString() { return Errors.Any() diff --git a/Src/RCommon.Models/ExecutionResults/IExecutionResult.cs b/Src/RCommon.Models/ExecutionResults/IExecutionResult.cs index 19a1c482..86ca1036 100644 --- a/Src/RCommon.Models/ExecutionResults/IExecutionResult.cs +++ b/Src/RCommon.Models/ExecutionResults/IExecutionResult.cs @@ -23,8 +23,18 @@ namespace RCommon.Models.ExecutionResults { + /// + /// Represents the outcome of an operation, indicating success or failure. + /// This is the base contract for all execution results used by commands and handlers. + /// + /// + /// + /// public interface IExecutionResult : IModel { + /// + /// Gets a value indicating whether the operation completed successfully. + /// bool IsSuccess { get; } } } diff --git a/Src/RCommon.Models/ExecutionResults/SuccessExecutionResult.cs b/Src/RCommon.Models/ExecutionResults/SuccessExecutionResult.cs index 0a206cfb..bf5aee44 100644 --- a/Src/RCommon.Models/ExecutionResults/SuccessExecutionResult.cs +++ b/Src/RCommon.Models/ExecutionResults/SuccessExecutionResult.cs @@ -25,12 +25,26 @@ namespace RCommon.Models.ExecutionResults { + /// + /// Represents a successful execution result with set to true. + /// + /// + /// Typically obtained via rather than instantiated directly, + /// which returns a cached singleton to reduce allocations. + /// + /// + /// [DataContract] public record SuccessExecutionResult : ExecutionResult { + /// [DataMember] public override bool IsSuccess { get; } = true; + /// + /// Returns a human-readable string indicating successful execution. + /// + /// The string "Successful execution". public override string ToString() { return "Successful execution"; diff --git a/Src/RCommon.Models/IModel.cs b/Src/RCommon.Models/IModel.cs index b037d026..6e531b85 100644 --- a/Src/RCommon.Models/IModel.cs +++ b/Src/RCommon.Models/IModel.cs @@ -6,6 +6,11 @@ namespace RCommon.Models { + /// + /// Base marker interface for all models in the RCommon framework. + /// Implementing this interface identifies a type as an RCommon model, enabling + /// consistent type constraints across commands, queries, events, and execution results. + /// public interface IModel { } diff --git a/Src/RCommon.Models/IPaginatedListRequest.cs b/Src/RCommon.Models/IPaginatedListRequest.cs index fda9b160..99c3b127 100644 --- a/Src/RCommon.Models/IPaginatedListRequest.cs +++ b/Src/RCommon.Models/IPaginatedListRequest.cs @@ -1,10 +1,32 @@ namespace RCommon.Models { + /// + /// Defines the contract for a paginated list request, specifying paging and sorting parameters + /// used to retrieve a subset of data from a larger collection. + /// + /// + /// public interface IPaginatedListRequest : IModel { + /// + /// Gets or sets the one-based page number to retrieve. + /// int PageNumber { get; set; } + + /// + /// Gets or sets the number of items per page. + /// int PageSize { get; set; } - string SortBy { get; set; } + + /// + /// Gets or sets the name of the property to sort by. + /// + string? SortBy { get; set; } + + /// + /// Gets or sets the sort direction (ascending, descending, or none). + /// + /// SortDirectionEnum SortDirection { get; set; } } } diff --git a/Src/RCommon.Models/ISearchPaginatedListRequest.cs b/Src/RCommon.Models/ISearchPaginatedListRequest.cs index a3501cc6..0e3dd6a2 100644 --- a/Src/RCommon.Models/ISearchPaginatedListRequest.cs +++ b/Src/RCommon.Models/ISearchPaginatedListRequest.cs @@ -1,7 +1,16 @@ namespace RCommon.Models { + /// + /// Extends paginated list requests with a free-text search capability. + /// Implementations combine search filtering with pagination and sorting. + /// + /// + /// public interface ISearchPaginatedListRequest : IModel { - string SearchString { get; set; } + /// + /// Gets or sets the search string used to filter results. + /// + string? SearchString { get; set; } } } diff --git a/Src/RCommon.Models/PaginatedListModel.cs b/Src/RCommon.Models/PaginatedListModel.cs index fca80039..93b97f2f 100644 --- a/Src/RCommon.Models/PaginatedListModel.cs +++ b/Src/RCommon.Models/PaginatedListModel.cs @@ -11,17 +11,35 @@ namespace RCommon.Models /// Represents a Data Transfer Object (DTO) that is typically used to encapsulate a PaginatedList so that it can be /// delivered to the application layer. This should be an immutable object. /// + /// The source entity type from the data layer. + /// The output/projected type exposed to consumers (e.g., a view model or DTO). + /// + /// This two-type-parameter variant allows projecting source entities into a different output type + /// via the abstract method. + /// [DataContract] public abstract record PaginatedListModel : IModel where TSource : class where TOut : class { + /// + /// Initializes a new instance of + /// by paginating the provided queryable source. + /// + /// The queryable data source to paginate. + /// The request containing paging and sorting parameters. protected PaginatedListModel(IQueryable source, PaginatedListRequest paginatedListRequest) { PaginateQueryable(source, paginatedListRequest); } + /// + /// Applies pagination and sorting parameters to the source queryable and populates the model properties. + /// + /// The queryable data source to paginate. Must not be null. + /// The request containing paging and sorting parameters. Must not be null. + /// Thrown when or is null. protected void PaginateQueryable(IQueryable source, PaginatedListRequest paginatedListRequest) { if (source == null) @@ -34,41 +52,76 @@ protected void PaginateQueryable(IQueryable source, PaginatedListReques throw new ArgumentException("Request input cannot be null"); } + // Default sort field to "id" when none is specified. SortBy = paginatedListRequest.SortBy ?? "id"; SortDirection = paginatedListRequest.SortDirection; PageSize = paginatedListRequest.PageSize; PageNumber = paginatedListRequest.PageNumber; TotalCount = source.Count(); + + // Calculate total pages using ceiling division: add 1 if there is a remainder. TotalPages = TotalCount / PageSize + (TotalCount % PageSize > 0 ? 1 : 0); + // Skip to the requested page (one-based) and take only the page size number of items. var query = source.Skip(PageSize * (PageNumber - 1)).Take(PageSize); + // Project source items to the output type via the abstract CastItems method. Items = CastItems(query).ToList(); } + /// + /// When implemented in a derived class, projects source entities to the output type. + /// + /// The queryable containing the current page of source entities. + /// A queryable of projected output items. protected abstract IQueryable CastItems(IQueryable source); + /// + /// Gets or sets the list of items for the current page, projected to . + /// [DataMember] - public List Items { get; set; } + public List Items { get; set; } = new List(); + /// + /// Gets or sets the number of items per page. + /// [DataMember] public int PageSize { get; set; } + /// + /// Gets or sets the one-based page number. + /// [DataMember] public int PageNumber { get; set; } + /// + /// Gets or sets the total number of pages available. + /// [DataMember] public int TotalPages { get; set; } + /// + /// Gets or sets the total count of items across all pages. + /// [DataMember] public int TotalCount { get; set; } + /// + /// Gets or sets the name of the property used for sorting. + /// [DataMember] - public string SortBy { get; set; } + public string? SortBy { get; set; } + /// + /// Gets or sets the sort direction applied to the results. + /// + /// [DataMember] public SortDirectionEnum SortDirection { get; set; } + /// + /// Gets a value indicating whether there is a page before the current page. + /// [DataMember] public bool HasPreviousPage { @@ -78,6 +131,9 @@ public bool HasPreviousPage } } + /// + /// Gets a value indicating whether there is a page after the current page. + /// [DataMember] public bool HasNextPage { @@ -92,16 +148,34 @@ public bool HasNextPage /// Represents a Data Transfer Object (DTO) that is typically used to encapsulate a PaginatedList so that it can be /// delivered to the application layer. This should be an immutable object. /// + /// The entity type contained in the paginated list. + /// + /// This single-type-parameter variant returns items in their original source type + /// without projection. For source-to-output projection, use + /// instead. + /// [DataContract] public abstract record PaginatedListModel : IModel where TSource : class { + /// + /// Initializes a new instance of + /// by paginating the provided queryable source. + /// + /// The queryable data source to paginate. + /// The request containing paging and sorting parameters. protected PaginatedListModel(IQueryable source, PaginatedListRequest paginatedListRequest) { PaginateQueryable(source, paginatedListRequest); } + /// + /// Applies pagination and sorting parameters to the source queryable and populates the model properties. + /// + /// The queryable data source to paginate. Must not be null. + /// The request containing paging and sorting parameters. Must not be null. + /// Thrown when or is null. protected void PaginateQueryable(IQueryable source, PaginatedListRequest paginatedListRequest) { if (source == null) @@ -114,37 +188,66 @@ protected void PaginateQueryable(IQueryable source, PaginatedListReques throw new ArgumentException("Request input cannot be null"); } + // Default sort field to "id" when none is specified. SortBy = paginatedListRequest.SortBy ?? "id"; SortDirection = paginatedListRequest.SortDirection; PageSize = paginatedListRequest.PageSize; PageNumber = paginatedListRequest.PageNumber; TotalCount = source.Count(); + + // Calculate total pages using ceiling division: add 1 if there is a remainder. TotalPages = TotalCount / PageSize + (TotalCount % PageSize > 0 ? 1 : 0); + // Skip to the requested page (one-based) and take only the page size number of items. Items = source.Skip(PageSize * (PageNumber - 1)).Take(PageSize).ToList(); } + /// + /// Gets or sets the list of items for the current page. + /// [DataMember] - public List Items { get; set; } + public List Items { get; set; } = new List(); + /// + /// Gets or sets the number of items per page. + /// [DataMember] public int PageSize { get; set; } + /// + /// Gets or sets the one-based page number. + /// [DataMember] public int PageNumber { get; set; } + /// + /// Gets or sets the total number of pages available. + /// [DataMember] public int TotalPages { get; set; } + /// + /// Gets or sets the total count of items across all pages. + /// [DataMember] public int TotalCount { get; set; } + /// + /// Gets or sets the name of the property used for sorting. + /// [DataMember] - public string SortBy { get; set; } + public string? SortBy { get; set; } + /// + /// Gets or sets the sort direction applied to the results. + /// + /// [DataMember] public SortDirectionEnum SortDirection { get; set; } + /// + /// Gets a value indicating whether there is a page before the current page. + /// [DataMember] public bool HasPreviousPage { @@ -154,6 +257,9 @@ public bool HasPreviousPage } } + /// + /// Gets a value indicating whether there is a page after the current page. + /// [DataMember] public bool HasNextPage { diff --git a/Src/RCommon.Models/PaginatedListRequest.cs b/Src/RCommon.Models/PaginatedListRequest.cs index 663c9f00..aa5c8663 100644 --- a/Src/RCommon.Models/PaginatedListRequest.cs +++ b/Src/RCommon.Models/PaginatedListRequest.cs @@ -3,9 +3,22 @@ namespace RCommon.Models { + /// + /// Abstract base record providing default pagination and sorting parameters. + /// Derive from this class to create concrete paginated list request types. + /// + /// + /// Defaults: page 1, 20 items per page, sorted by "id" with no sort direction. + /// + /// + /// [DataContract] public abstract record PaginatedListRequest : IPaginatedListRequest { + /// + /// Initializes a new instance of with default values: + /// page 1, page size 20, sort by "id", and no sort direction. + /// public PaginatedListRequest() { PageNumber = 1; @@ -14,15 +27,19 @@ public PaginatedListRequest() SortDirection = SortDirectionEnum.None; } + /// [DataMember] public virtual int PageNumber { get; set; } + /// [DataMember] public virtual int PageSize { get; set; } + /// [DataMember] - public virtual string SortBy { get; set; } + public virtual string? SortBy { get; set; } + /// [DataMember] public virtual SortDirectionEnum SortDirection { get; set; } } diff --git a/Src/RCommon.Models/Queries/IQuery.cs b/Src/RCommon.Models/Queries/IQuery.cs index 75e34d01..aa555a7f 100644 --- a/Src/RCommon.Models/Queries/IQuery.cs +++ b/Src/RCommon.Models/Queries/IQuery.cs @@ -6,10 +6,20 @@ namespace RCommon.Models.Queries { + /// + /// Marker interface representing a query in the CQRS pattern. + /// Queries encapsulate the intent to read data without modifying system state. + /// public interface IQuery { } + /// + /// Generic query interface that specifies the expected result type. + /// Use this interface when a query handler should return a typed result. + /// + /// The type of result returned by the query handler. + /// public interface IQuery : IQuery { } diff --git a/Src/RCommon.Models/RCommon.Models.csproj b/Src/RCommon.Models/RCommon.Models.csproj index 200f3396..cd5100bc 100644 --- a/Src/RCommon.Models/RCommon.Models.csproj +++ b/Src/RCommon.Models/RCommon.Models.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Models https://rcommon.com diff --git a/Src/RCommon.Models/README.md b/Src/RCommon.Models/README.md index 681a2e58..0cd1e9da 100644 --- a/Src/RCommon.Models/README.md +++ b/Src/RCommon.Models/README.md @@ -1,3 +1,87 @@ # RCommon.Models -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Shared model interfaces and base types for the RCommon framework, providing CQRS command and query contracts, event marker interfaces for sync/async dispatch, pagination models, and execution result types for conveying operation outcomes. + +## Features + +- `ICommand` and `ICommand` marker interfaces for CQRS command segregation +- `IQuery` and `IQuery` marker interfaces for CQRS query segregation +- `ISerializableEvent`, `ISyncEvent`, and `IAsyncEvent` marker interfaces for controlling event dispatch behavior +- `CommandResult` record for wrapping command handler outcomes +- `ExecutionResult` with static factory methods for `Success()` and `Failed(errors)` using cached singletons +- `PaginatedListModel` and `PaginatedListModel` abstract records for building paginated DTOs with built-in page calculation +- `PaginatedListRequest` and `SearchPaginatedListRequest` for encapsulating paging, sorting, and search parameters +- `SortDirectionEnum` for specifying ascending, descending, or no-sort ordering +- All models are decorated with `DataContract`/`DataMember` attributes for serialization support + +## Installation + +```shell +dotnet add package RCommon.Models +``` + +## Usage + +```csharp +using RCommon.Models.Commands; +using RCommon.Models.Queries; +using RCommon.Models.Events; +using RCommon.Models.ExecutionResults; + +// Define a command +public class CreateOrderCommand : ICommand +{ + public string ProductName { get; set; } + public int Quantity { get; set; } +} + +// Define an event for async dispatch +public class OrderCreatedEvent : IAsyncEvent +{ + public Guid OrderId { get; set; } +} + +// Return execution results from handlers +IExecutionResult result = ExecutionResult.Success(); +IExecutionResult failure = ExecutionResult.Failed("Insufficient inventory", "Payment declined"); + +// Use paginated requests +var request = new SearchPaginatedListRequest +{ + PageNumber = 1, + PageSize = 25, + SortBy = "CreatedDate", + SortDirection = SortDirectionEnum.Descending, + SearchString = "widget" +}; +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IModel` | Base marker interface for all RCommon models | +| `ICommand` / `ICommand` | Marker interfaces for CQRS commands, optionally specifying a result type | +| `IQuery` / `IQuery` | Marker interfaces for CQRS queries, optionally specifying a result type | +| `ISerializableEvent` | Marker interface for events that can be serialized across process boundaries | +| `ISyncEvent` | Marker for events dispatched and handled synchronously within the same process | +| `IAsyncEvent` | Marker for events dispatched asynchronously via a message bus or queue | +| `IExecutionResult` | Represents the outcome of an operation with an `IsSuccess` flag | +| `ExecutionResult` | Abstract base with `Success()` and `Failed()` factory methods | +| `CommandResult` | Wraps an execution result returned from a command handler | +| `PaginatedListModel` | Abstract paginated DTO with page size, count, and navigation properties | +| `PaginatedListRequest` | Base request with page number, page size, sort field, and sort direction | +| `SearchPaginatedListRequest` | Extends `PaginatedListRequest` with a free-text `SearchString` property | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Foundation package with event bus, builder pattern, guards, and extensions +- [RCommon.Entities](https://www.nuget.org/packages/RCommon.Entities) - Domain entity base classes with auditing and transactional event tracking + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Models/SearchPaginatedListRequest.cs b/Src/RCommon.Models/SearchPaginatedListRequest.cs index 3935b216..639e41d5 100644 --- a/Src/RCommon.Models/SearchPaginatedListRequest.cs +++ b/Src/RCommon.Models/SearchPaginatedListRequest.cs @@ -7,15 +7,27 @@ namespace RCommon.Models { + /// + /// A paginated list request that includes a free-text search filter. + /// Combines pagination/sorting from with + /// the search capability defined by . + /// + /// + /// [DataContract] public record SearchPaginatedListRequest : PaginatedListRequest, ISearchPaginatedListRequest { + /// + /// Initializes a new instance of + /// with default pagination values inherited from . + /// public SearchPaginatedListRequest() { } + /// [DataMember] - public string SearchString { get; set; } + public string? SearchString { get; set; } } } diff --git a/Src/RCommon.Models/SortDirectionEnum.cs b/Src/RCommon.Models/SortDirectionEnum.cs index 24a82cc2..713c4a88 100644 --- a/Src/RCommon.Models/SortDirectionEnum.cs +++ b/Src/RCommon.Models/SortDirectionEnum.cs @@ -7,12 +7,26 @@ namespace RCommon.Models { + /// + /// Specifies the direction in which a collection should be sorted. + /// [DataContract] public enum SortDirectionEnum : byte { + /// + /// Sort in ascending order (A-Z, 0-9). + /// Ascending = 1, + + /// + /// Sort in descending order (Z-A, 9-0). + /// Descending = 2, + + /// + /// No sorting is applied; the default order is preserved. + /// None = 3, - + } } diff --git a/Src/RCommon.Persistence.Caching.MemoryCache/IPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence.Caching.MemoryCache/IPersistenceBuilderExtensions.cs index 5e5a7749..b5339ff5 100644 --- a/Src/RCommon.Persistence.Caching.MemoryCache/IPersistenceBuilderExtensions.cs +++ b/Src/RCommon.Persistence.Caching.MemoryCache/IPersistenceBuilderExtensions.cs @@ -12,8 +12,17 @@ namespace RCommon.Persistence.Caching.MemoryCache { + /// + /// Extension methods on for registering memory-based + /// persistence caching (both in-process and distributed memory). + /// public static class IPersistenceBuilderExtensions { + /// + /// Registers persistence caching backed by the in-process , + /// including all caching repository decorators and the strategy-based cache factory. + /// + /// The persistence builder. public static void AddInMemoryPersistenceCaching(this IPersistenceBuilder builder) { // Add Caching services @@ -22,20 +31,26 @@ public static void AddInMemoryPersistenceCaching(this IPersistenceBuilder builde builder.Services.TryAddTransient, CommonFactory>(); ConfigureCachingOptions(builder); - // Add Caching Factory + // Add Caching Factory — resolves the correct ICacheService based on the PersistenceCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case PersistenceCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } }); - + } + /// + /// Registers persistence caching backed by the + /// (an in-memory distributed cache), including all caching repository decorators + /// and the strategy-based cache factory. + /// + /// The persistence builder. public static void AddDistributedMemoryPersistenceCaching(this IPersistenceBuilder builder) { // Add Caching services @@ -44,20 +59,26 @@ public static void AddDistributedMemoryPersistenceCaching(this IPersistenceBuild builder.Services.TryAddTransient, CommonFactory>(); ConfigureCachingOptions(builder); - // Add Caching Factory + // Add Caching Factory — resolves the correct ICacheService based on the PersistenceCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case PersistenceCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } - }); + }); } - private static void ConfigureCachingOptions(IPersistenceBuilder builder, Action configure = null) + /// + /// Registers the open-generic caching repository decorators and configures + /// with default or custom settings. + /// + /// The persistence builder. + /// An optional delegate to customize . When null, defaults are applied. + private static void ConfigureCachingOptions(IPersistenceBuilder builder, Action? configure = null) { // Add Caching repositories builder.Services.TryAddTransient(typeof(ICachingGraphRepository<>), typeof(CachingGraphRepository<>)); diff --git a/Src/RCommon.Persistence.Caching.MemoryCache/README.md b/Src/RCommon.Persistence.Caching.MemoryCache/README.md index cfa43776..6b7b7e3b 100644 --- a/Src/RCommon.Persistence.Caching.MemoryCache/README.md +++ b/Src/RCommon.Persistence.Caching.MemoryCache/README.md @@ -1,3 +1,56 @@ # RCommon.Persistence.Caching.MemoryCache -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Wires memory-based caching into RCommon's persistence caching decorators, providing `AddInMemoryPersistenceCaching()` and `AddDistributedMemoryPersistenceCaching()` extension methods that register the appropriate `ICacheService` implementation and all caching repository decorators. + +## Features + +- `AddInMemoryPersistenceCaching()` -- registers `InMemoryCacheService` (backed by `IMemoryCache`) as the cache provider for persistence caching decorators +- `AddDistributedMemoryPersistenceCaching()` -- registers `DistributedMemoryCacheService` (backed by `IDistributedCache` in-memory store) as the cache provider +- Automatically registers all open-generic caching repository decorators (`CachingGraphRepository<>`, `CachingLinqRepository<>`, `CachingSqlMapperRepository<>`) +- Configures `CachingOptions` with caching enabled by default +- Strategy-based factory resolves the correct `ICacheService` via `PersistenceCachingStrategy` + +## Installation + +```shell +dotnet add package RCommon.Persistence.Caching.MemoryCache +``` + +## Usage + +```csharp +using RCommon; +using RCommon.Persistence.Caching.MemoryCache; + +services.AddRCommon(builder => +{ + builder.WithPersistence(persistence => + { + // Option 1: In-process IMemoryCache for persistence caching + persistence.AddInMemoryPersistenceCaching(); + + // Option 2: Distributed memory cache for persistence caching + persistence.AddDistributedMemoryPersistenceCaching(); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IPersistenceBuilderExtensions` | Provides `AddInMemoryPersistenceCaching()` and `AddDistributedMemoryPersistenceCaching()` on `IPersistenceBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Persistence.Caching](https://www.nuget.org/packages/RCommon.Persistence.Caching) - Core persistence caching decorators and abstractions +- [RCommon.MemoryCache](https://www.nuget.org/packages/RCommon.MemoryCache) - Underlying `InMemoryCacheService` and `DistributedMemoryCacheService` implementations +- [RCommon.Persistence.Caching.RedisCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.RedisCache) - Redis-backed alternative for persistence caching + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Persistence.Caching.RedisCache/IPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence.Caching.RedisCache/IPersistenceBuilderExtensions.cs index cefc079c..97375fdf 100644 --- a/Src/RCommon.Persistence.Caching.RedisCache/IPersistenceBuilderExtensions.cs +++ b/Src/RCommon.Persistence.Caching.RedisCache/IPersistenceBuilderExtensions.cs @@ -11,31 +11,46 @@ namespace RCommon.Persistence.Caching.RedisCache { + /// + /// Extension methods on for registering Redis-backed + /// persistence caching. + /// public static class IPersistenceBuilderExtensions { + /// + /// Registers persistence caching backed by the , + /// including all caching repository decorators and the strategy-based cache factory. + /// + /// The persistence builder. public static void AddRedisPersistenceCaching(this IPersistenceBuilder builder) { - // Add Caching repositories + // Add Caching services builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); builder.Services.TryAddTransient, CommonFactory>(); ConfigureCachingOptions(builder); - // Add Caching services + // Add Caching Factory — resolves the correct ICacheService based on the PersistenceCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case PersistenceCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } }); } - private static void ConfigureCachingOptions(IPersistenceBuilder builder, Action configure = null) + /// + /// Registers the open-generic caching repository decorators and configures + /// with default or custom settings. + /// + /// The persistence builder. + /// An optional delegate to customize . When null, defaults are applied. + private static void ConfigureCachingOptions(IPersistenceBuilder builder, Action? configure = null) { // Add Caching repositories builder.Services.TryAddTransient(typeof(ICachingGraphRepository<>), typeof(CachingGraphRepository<>)); diff --git a/Src/RCommon.Persistence.Caching.RedisCache/README.md b/Src/RCommon.Persistence.Caching.RedisCache/README.md index 0bf0ca7f..5cf28c22 100644 --- a/Src/RCommon.Persistence.Caching.RedisCache/README.md +++ b/Src/RCommon.Persistence.Caching.RedisCache/README.md @@ -1,3 +1,63 @@ # RCommon.Persistence.Caching.RedisCache -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Wires Redis-based caching into RCommon's persistence caching decorators, providing the `AddRedisPersistenceCaching()` extension method that registers `RedisCacheService` and all caching repository decorators. + +## Features + +- `AddRedisPersistenceCaching()` -- registers `RedisCacheService` (backed by StackExchange.Redis via `IDistributedCache`) as the cache provider for persistence caching decorators +- Automatically registers all open-generic caching repository decorators (`CachingGraphRepository<>`, `CachingLinqRepository<>`, `CachingSqlMapperRepository<>`) +- Configures `CachingOptions` with caching enabled by default +- Strategy-based factory resolves the correct `ICacheService` via `PersistenceCachingStrategy` + +## Installation + +```shell +dotnet add package RCommon.Persistence.Caching.RedisCache +``` + +## Usage + +```csharp +using RCommon; +using RCommon.RedisCache; +using RCommon.Persistence.Caching.RedisCache; + +services.AddRCommon(builder => +{ + // Configure Redis connection + builder.WithDistributedCaching(cache => + { + cache.Configure(options => + { + options.Configuration = "localhost:6379"; + options.InstanceName = "MyApp:"; + }); + }); + + builder.WithPersistence(persistence => + { + // Use Redis as the cache provider for persistence caching + persistence.AddRedisPersistenceCaching(); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IPersistenceBuilderExtensions` | Provides `AddRedisPersistenceCaching()` on `IPersistenceBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Persistence.Caching](https://www.nuget.org/packages/RCommon.Persistence.Caching) - Core persistence caching decorators and abstractions +- [RCommon.RedisCache](https://www.nuget.org/packages/RCommon.RedisCache) - Underlying `RedisCacheService` implementation +- [RCommon.Persistence.Caching.MemoryCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.MemoryCache) - Memory-based alternative for persistence caching + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs index 0badff41..942aad21 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs @@ -13,164 +13,212 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Decorator around that adds cache-aware query overloads. + /// Non-cached operations are delegated directly to the underlying repository. + /// + /// The entity type managed by this repository. + /// + /// Cached overloads use to retrieve results + /// from cache or fall through to the inner repository and store the result. + /// The is resolved via + /// using . + /// public class CachingGraphRepository : ICachingGraphRepository where TEntity : class, IBusinessEntity { private readonly IGraphRepository _repository; private readonly ICacheService _cacheService; + /// + /// Initializes a new instance of the class. + /// + /// The inner graph repository to delegate operations to. + /// Factory used to resolve the for the default persistence caching strategy. public CachingGraphRepository(IGraphRepository repository, ICommonFactory cacheFactory) { _repository = repository; _cacheService = cacheFactory.Create(PersistenceCachingStrategy.Default); } + /// public bool Tracking { get => _repository.Tracking; set => _repository.Tracking = value; } + /// public Type ElementType => _repository.ElementType; + /// public Expression Expression => _repository.Expression; + /// public IQueryProvider Provider => _repository.Provider; + /// public string DataStoreName { get => _repository.DataStoreName; set => _repository.DataStoreName = value; } + /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { await _repository.AddAsync(entity, token); } + /// public async Task AnyAsync(Expression> expression, CancellationToken token = default) { return await _repository.AnyAsync(expression, token); } + /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.AnyAsync(specification, token); } + /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { await _repository.DeleteAsync(entity, token); } + /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { return await _repository.FindAsync(primaryKey, token); } + /// public IQueryable FindQuery(ISpecification specification) { return _repository.FindQuery(specification); } + /// public IQueryable FindQuery(Expression> expression) { return _repository.FindQuery(expression); } + /// public IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) { return _repository.FindQuery(expression, orderByExpression, orderByAscending, pageNumber, pageSize); } + /// public IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending) { return _repository.FindQuery(expression, orderByExpression, orderByAscending); } + /// public IQueryable FindQuery(IPagedSpecification specification) { return _repository.FindQuery(specification); } + /// public async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(expression, token); } + /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(specification, token); } + /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { return await _repository.GetCountAsync(selectSpec, token); } + /// public async Task GetCountAsync(Expression> expression, CancellationToken token = default) { return await _repository.GetCountAsync(expression, token); } + /// public IEnumerator GetEnumerator() { return _repository.GetEnumerator(); } + /// public IEagerLoadableQueryable Include(Expression> path) { return _repository.Include(path); } + /// public IEagerLoadableQueryable ThenInclude(Expression> path) { return _repository.ThenInclude(path); } + /// public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { await _repository.UpdateAsync(entity, token); } + /// IEnumerator IEnumerable.GetEnumerator() { return _repository.GetEnumerator(); } + /// public async Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token); } + /// public async Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { return await _repository.FindAsync(specification, token); } + /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindAsync(specification, token); } + /// public async Task> FindAsync(Expression> expression, CancellationToken token = default) { return await _repository.FindAsync(expression, token); } + /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.DeleteManyAsync(specification, token); } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } - // Cached items + // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. + /// public async Task> FindAsync(object cacheKey, Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { - var data = await _cacheService.GetOrCreateAsync(cacheKey, + var data = await _cacheService.GetOrCreateAsync(cacheKey, async () => await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token)); return await data; } + /// public async Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, @@ -178,6 +226,7 @@ public async Task> FindAsync(object cacheKey, IPagedSpec return await data; } + /// public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, @@ -185,12 +234,14 @@ public async Task> FindAsync(object cacheKey, ISpecificatio return await data; } + /// public async Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, async () => await _repository.FindAsync(expression, token)); return await data; } + /// /// Adds a range of entities by delegating to the underlying repository. /// diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs index bc67ea41..85298c32 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs @@ -13,154 +13,201 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Decorator around (as an + /// implementation) that adds cache-aware query overloads. + /// Non-cached operations are delegated directly to the underlying repository. + /// + /// The entity type managed by this repository. + /// + /// Cached overloads use to retrieve results + /// from cache or fall through to the inner repository and store the result. + /// The is resolved via + /// using . + /// public class CachingLinqRepository : ICachingLinqRepository where TEntity : class, IBusinessEntity { private readonly IGraphRepository _repository; private readonly ICacheService _cacheService; + /// + /// Initializes a new instance of the class. + /// + /// The inner graph repository to delegate operations to. + /// Factory used to resolve the for the default persistence caching strategy. public CachingLinqRepository(IGraphRepository repository, ICommonFactory cacheFactory) { _repository = repository; _cacheService = cacheFactory.Create(PersistenceCachingStrategy.Default); } + /// public Type ElementType => _repository.ElementType; + /// public Expression Expression => _repository.Expression; + /// public IQueryProvider Provider => _repository.Provider; + /// public string DataStoreName { get => _repository.DataStoreName; set => _repository.DataStoreName = value; } + /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { await _repository.AddAsync(entity, token); } + /// public async Task AnyAsync(Expression> expression, CancellationToken token = default) { return await _repository.AnyAsync(expression, token); } + /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.AnyAsync(specification, token); } + /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { await _repository.DeleteAsync(entity, token); } + /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { return await _repository.FindAsync(primaryKey, token); } + /// public IQueryable FindQuery(ISpecification specification) { return _repository.FindQuery(specification); } + /// public IQueryable FindQuery(Expression> expression) { return _repository.FindQuery(expression); } + /// public IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) { return _repository.FindQuery(expression, orderByExpression, orderByAscending, pageNumber, pageSize); } + /// public IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending) { return _repository.FindQuery(expression, orderByExpression, orderByAscending); } + /// public IQueryable FindQuery(IPagedSpecification specification) { return _repository.FindQuery(specification); } + /// public async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(expression, token); } + /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(specification, token); } + /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { return await _repository.GetCountAsync(selectSpec, token); } + /// public async Task GetCountAsync(Expression> expression, CancellationToken token = default) { return await _repository.GetCountAsync(expression, token); } + /// public IEnumerator GetEnumerator() { return _repository.GetEnumerator(); } + /// public IEagerLoadableQueryable Include(Expression> path) { return _repository.Include(path); } + /// public IEagerLoadableQueryable ThenInclude(Expression> path) { return _repository.ThenInclude(path); } + /// public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { await _repository.UpdateAsync(entity, token); } + /// IEnumerator IEnumerable.GetEnumerator() { return _repository.GetEnumerator(); } + /// public async Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token); } + /// public async Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { return await _repository.FindAsync(specification, token); } + /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindAsync(specification, token); } + /// public async Task> FindAsync(Expression> expression, CancellationToken token = default) { return await _repository.FindAsync(expression, token); } + /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.DeleteManyAsync(specification, token); } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } - // Cached items + // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. + /// public async Task> FindAsync(object cacheKey, Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { @@ -169,6 +216,7 @@ public async Task> FindAsync(object cacheKey, Expression return await data; } + /// public async Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, @@ -176,6 +224,7 @@ public async Task> FindAsync(object cacheKey, IPagedSpec return await data; } + /// public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, @@ -183,12 +232,14 @@ public async Task> FindAsync(object cacheKey, ISpecificatio return await data; } + /// public async Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, async () => await _repository.FindAsync(expression, token)); return await data; } + /// /// Adds a range of entities by delegating to the underlying repository. /// diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs index fb188a9d..7a9c5ff2 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs @@ -11,93 +11,127 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Decorator around that adds cache-aware query overloads. + /// Non-cached operations are delegated directly to the underlying repository. + /// + /// The entity type managed by this repository. + /// + /// Cached overloads use to retrieve results + /// from cache or fall through to the inner repository and store the result. + /// The is resolved via + /// using . + /// public class CachingSqlMapperRepository : ICachingSqlMapperRepository where TEntity : class, IBusinessEntity { private readonly ISqlMapperRepository _repository; private readonly ICacheService _cacheService; + /// + /// Initializes a new instance of the class. + /// + /// The inner SQL mapper repository to delegate operations to. + /// Factory used to resolve the for the default persistence caching strategy. public CachingSqlMapperRepository(ISqlMapperRepository repository, ICommonFactory cacheFactory) { _repository = repository; _cacheService = cacheFactory.Create(PersistenceCachingStrategy.Default); } + /// public string TableName { get => _repository.TableName; set => _repository.TableName = value; } + + /// public string DataStoreName { get => _repository.DataStoreName; set => _repository.DataStoreName = value; } + /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { await _repository.AddAsync(entity, token); } + /// public async Task AnyAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { return await _repository.AnyAsync(expression, token); } + /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.AnyAsync(specification, token); } + /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { await _repository.DeleteAsync(entity, token); } + /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindAsync(specification, token); } + /// public async Task> FindAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { return await _repository.FindAsync(expression, token); } + /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { return await _repository.FindAsync(primaryKey, token); } + /// public async Task FindSingleOrDefaultAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(expression, token); } + /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { return await _repository.FindSingleOrDefaultAsync(specification, token); } + /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { return await _repository.GetCountAsync(selectSpec, token); } + /// public async Task GetCountAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { return await _repository.GetCountAsync(expression, token); } + /// public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { await _repository.UpdateAsync(entity, token); } + /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await _repository.DeleteManyAsync(specification, token); } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } - // Cached Items + // Cached Items — these overloads check the cache first and fall through to the inner repository on a miss. + /// public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, @@ -105,12 +139,14 @@ public async Task> FindAsync(object cacheKey, ISpecificatio return await data; } + /// public async Task> FindAsync(object cacheKey, System.Linq.Expressions.Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, async () => await _repository.FindAsync(expression, token)); return await data; } + /// /// Adds a range of entities by delegating to the underlying repository. /// diff --git a/Src/RCommon.Persistence.Caching/Crud/ICachingGraphRepository.cs b/Src/RCommon.Persistence.Caching/Crud/ICachingGraphRepository.cs index d6d642ba..948a20bd 100644 --- a/Src/RCommon.Persistence.Caching/Crud/ICachingGraphRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/ICachingGraphRepository.cs @@ -11,12 +11,55 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Extends with cache-aware query overloads that accept a cache key. + /// + /// The entity type managed by this repository. + /// + /// Each overload mirrors a standard FindAsync method but adds an object cacheKey + /// parameter so results can be stored in and retrieved from a cache layer before hitting the data store. + /// public interface ICachingGraphRepository : IGraphRepository where TEntity : class, IBusinessEntity { + /// + /// Finds a paginated list of entities matching the expression, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// A filter expression for the query. + /// An expression selecting the property to order by. + /// true to sort ascending; false for descending. + /// The 1-based page number. + /// The number of items per page (0 for all). + /// A cancellation token. + /// A paginated list of matching entities. Task> FindAsync(object cacheKey, Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default); + + /// + /// Finds a paginated list of entities matching the paged specification, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// The paged specification defining filter, ordering, and paging. + /// A cancellation token. + /// A paginated list of matching entities. Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default); + + /// + /// Finds a collection of entities matching the specification, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// The specification defining the query filter. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default); + + /// + /// Finds a collection of entities matching the expression, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// A filter expression for the query. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default); } } diff --git a/Src/RCommon.Persistence.Caching/Crud/ICachingLinqRepository.cs b/Src/RCommon.Persistence.Caching/Crud/ICachingLinqRepository.cs index 000f09a2..f1d4c9a2 100644 --- a/Src/RCommon.Persistence.Caching/Crud/ICachingLinqRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/ICachingLinqRepository.cs @@ -11,12 +11,55 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Extends with cache-aware query overloads that accept a cache key. + /// + /// The entity type managed by this repository. + /// + /// Each overload mirrors a standard FindAsync method but adds an object cacheKey + /// parameter so results can be stored in and retrieved from a cache layer before hitting the data store. + /// public interface ICachingLinqRepository : ILinqRepository where TEntity : class, IBusinessEntity { + /// + /// Finds a paginated list of entities matching the expression, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// A filter expression for the query. + /// An expression selecting the property to order by. + /// true to sort ascending; false for descending. + /// The 1-based page number. + /// The number of items per page (0 for all). + /// A cancellation token. + /// A paginated list of matching entities. Task> FindAsync(object cacheKey, Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default); + + /// + /// Finds a paginated list of entities matching the paged specification, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// The paged specification defining filter, ordering, and paging. + /// A cancellation token. + /// A paginated list of matching entities. Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default); + + /// + /// Finds a collection of entities matching the specification, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// The specification defining the query filter. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default); + + /// + /// Finds a collection of entities matching the expression, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// A filter expression for the query. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default); } } diff --git a/Src/RCommon.Persistence.Caching/Crud/ICachingSqlMapperRepository.cs b/Src/RCommon.Persistence.Caching/Crud/ICachingSqlMapperRepository.cs index 8596b525..b4b02eec 100644 --- a/Src/RCommon.Persistence.Caching/Crud/ICachingSqlMapperRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/ICachingSqlMapperRepository.cs @@ -10,10 +10,33 @@ namespace RCommon.Persistence.Caching.Crud { + /// + /// Extends with cache-aware query overloads that accept a cache key. + /// + /// The entity type managed by this repository. + /// + /// Each overload mirrors a standard FindAsync method but adds an object cacheKey + /// parameter so results can be stored in and retrieved from a cache layer before hitting the data store. + /// public interface ICachingSqlMapperRepository : ISqlMapperRepository where TEntity : class, IBusinessEntity { + /// + /// Finds a collection of entities matching the specification, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// The specification defining the query filter. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default); + + /// + /// Finds a collection of entities matching the expression, caching the result under the specified key. + /// + /// The key used to store/retrieve the cached result. + /// A filter expression for the query. + /// A cancellation token. + /// A collection of matching entities. Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default); } } diff --git a/Src/RCommon.Persistence.Caching/IPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence.Caching/IPersistenceBuilderExtensions.cs index 06096e7d..f6d7b05a 100644 --- a/Src/RCommon.Persistence.Caching/IPersistenceBuilderExtensions.cs +++ b/Src/RCommon.Persistence.Caching/IPersistenceBuilderExtensions.cs @@ -10,8 +10,25 @@ namespace RCommon.Persistence.Caching { + /// + /// Extension methods on for registering persistence-level caching + /// with a custom factory. + /// public static class IPersistenceBuilderExtensions { + /// + /// Registers persistence caching infrastructure including the cache service factory, + /// all caching repository decorators, and default . + /// + /// The persistence builder. + /// + /// A factory that, given an , returns a delegate resolving + /// from a . + /// + /// + /// This overload lets callers supply their own cache factory delegate, which is useful when + /// the caching provider is not one of the built-in memory or Redis implementations. + /// public static void AddPersistenceCaching(this IPersistenceBuilder builder, Func> cacheFactory) { // Add caching services diff --git a/Src/RCommon.Persistence.Caching/PersistenceCachingStrategy.cs b/Src/RCommon.Persistence.Caching/PersistenceCachingStrategy.cs index 0897394d..97e6f23b 100644 --- a/Src/RCommon.Persistence.Caching/PersistenceCachingStrategy.cs +++ b/Src/RCommon.Persistence.Caching/PersistenceCachingStrategy.cs @@ -6,8 +6,18 @@ namespace RCommon.Persistence.Caching { + /// + /// Defines the strategy used when caching persistence (repository) query results. + /// + /// + /// Used as the key type for to resolve + /// the appropriate implementation for caching repositories at runtime. + /// public enum PersistenceCachingStrategy { + /// + /// The default persistence caching strategy. + /// Default } } diff --git a/Src/RCommon.Persistence.Caching/RCommon.Persistence.Caching.csproj b/Src/RCommon.Persistence.Caching/RCommon.Persistence.Caching.csproj index 77ae4197..388e2e6b 100644 --- a/Src/RCommon.Persistence.Caching/RCommon.Persistence.Caching.csproj +++ b/Src/RCommon.Persistence.Caching/RCommon.Persistence.Caching.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Persistence.Caching https://rcommon.com diff --git a/Src/RCommon.Persistence.Caching/README.md b/Src/RCommon.Persistence.Caching/README.md index 9f562963..c065ea3b 100644 --- a/Src/RCommon.Persistence.Caching/README.md +++ b/Src/RCommon.Persistence.Caching/README.md @@ -1,3 +1,83 @@ # RCommon.Persistence.Caching -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +Provides caching decorator repositories that wrap any RCommon persistence provider with a cache layer, enabling cache-aware query overloads on `IGraphRepository`, `ILinqRepository`, and `ISqlMapperRepository`. + +## Features + +- `CachingGraphRepository` -- decorator around `IGraphRepository` that adds cache-aware `FindAsync` overloads accepting a cache key +- `CachingLinqRepository` -- decorator around `ILinqRepository` (via `IGraphRepository`) with the same cached query pattern +- `CachingSqlMapperRepository` -- decorator around `ISqlMapperRepository` with cached `FindAsync` overloads +- Non-cached operations (Add, Update, Delete, etc.) are delegated directly to the inner repository +- `PersistenceCachingStrategy` enum for strategy-based resolution of the `ICacheService` used by repository decorators +- `AddPersistenceCaching()` extension on `IPersistenceBuilder` to register all caching repository decorators with a custom cache factory + +## Installation + +```shell +dotnet add package RCommon.Persistence.Caching +``` + +## Usage + +```csharp +using RCommon; +using RCommon.Persistence.Caching; +using RCommon.Persistence.Caching.Crud; + +// Register persistence caching with a custom cache factory +services.AddRCommon(builder => +{ + builder.WithPersistence(persistence => + { + persistence.AddPersistenceCaching(serviceProvider => strategy => + { + return serviceProvider.GetRequiredService(); + }); + }); +}); + +// Use the caching repository in your service +public class ProductService +{ + private readonly ICachingGraphRepository _repo; + + public ProductService(ICachingGraphRepository repo) + { + _repo = repo; + } + + public async Task> GetActiveProductsAsync() + { + // Cached query -- checks cache first, falls through to the database on a miss + return await _repo.FindAsync( + CacheKey.With("active-products"), + x => x.IsActive); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `CachingGraphRepository` | Decorator adding cached `FindAsync` overloads to `IGraphRepository` | +| `CachingLinqRepository` | Decorator adding cached `FindAsync` overloads to `ILinqRepository` | +| `CachingSqlMapperRepository` | Decorator adding cached `FindAsync` overloads to `ISqlMapperRepository` | +| `ICachingGraphRepository` | Interface extending `IGraphRepository` with cache-key-based query methods | +| `ICachingLinqRepository` | Interface extending `ILinqRepository` with cache-key-based query methods | +| `ICachingSqlMapperRepository` | Interface extending `ISqlMapperRepository` with cache-key-based query methods | +| `PersistenceCachingStrategy` | Strategy enum for resolving the `ICacheService` used by caching repositories | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Caching](https://www.nuget.org/packages/RCommon.Caching) - Core caching abstractions (`ICacheService`, `CacheKey`) +- [RCommon.Persistence.Caching.MemoryCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.MemoryCache) - Wires in-memory caching into persistence caching decorators +- [RCommon.Persistence.Caching.RedisCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.RedisCache) - Wires Redis caching into persistence caching decorators + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Persistence/Crud/GraphRepositoryBase.cs b/Src/RCommon.Persistence/Crud/GraphRepositoryBase.cs index 71616d0d..252d1564 100644 --- a/Src/RCommon.Persistence/Crud/GraphRepositoryBase.cs +++ b/Src/RCommon.Persistence/Crud/GraphRepositoryBase.cs @@ -15,20 +15,32 @@ namespace RCommon.Persistence.Crud { /// - /// A base class for implementors of . + /// A base class for implementors of that provides + /// graph-based (change-tracked) repository functionality on top of . /// - /// + ///The entity type, which must be a class implementing . + /// + /// Concrete implementations (e.g., EF Core repositories) should inherit from this class and provide + /// the actual data access logic including change tracking behavior via the property. + /// public abstract class GraphRepositoryBase : LinqRepositoryBase, IGraphRepository where TEntity : class, IBusinessEntity { + /// + /// Initializes a new instance of the class. + /// + /// The factory used to resolve named data stores. + /// The entity event tracker for publishing domain events. + /// Options specifying the default data store name. public GraphRepositoryBase(IDataStoreFactory dataStoreFactory, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) :base(dataStoreFactory, eventTracker, defaultDataStoreOptions) { - + } + /// public abstract bool Tracking { get; set; } } } diff --git a/Src/RCommon.Persistence/Crud/IEagerLoadableQueryable.cs b/Src/RCommon.Persistence/Crud/IEagerLoadableQueryable.cs index c4305bd0..9391e23e 100644 --- a/Src/RCommon.Persistence/Crud/IEagerLoadableQueryable.cs +++ b/Src/RCommon.Persistence/Crud/IEagerLoadableQueryable.cs @@ -8,9 +8,24 @@ namespace RCommon.Persistence.Crud { + /// + /// Extends and with support + /// for chained eager loading of related navigation properties. + /// + /// The entity type, which must implement . + /// + /// This interface enables fluent Include(...).ThenInclude(...) chains similar to Entity Framework Core. + /// public interface IEagerLoadableQueryable : IQueryable, IReadOnlyRepository where TEntity : IBusinessEntity { + /// + /// Eagerly loads a nested navigation property following a prior Include call. + /// + /// The type of the previously included property. + /// The type of the nested property to include. + /// An expression specifying the nested navigation property to include. + /// An for further chaining. IEagerLoadableQueryable ThenInclude(Expression> path); } } diff --git a/Src/RCommon.Persistence/Crud/IGraphRepository.cs b/Src/RCommon.Persistence/Crud/IGraphRepository.cs index 2791f0e4..ebd9bde7 100644 --- a/Src/RCommon.Persistence/Crud/IGraphRepository.cs +++ b/Src/RCommon.Persistence/Crud/IGraphRepository.cs @@ -10,10 +10,23 @@ namespace RCommon.Persistence.Crud { + /// + /// A repository that supports graph-based (change-tracked) operations on entities of type , + /// extending with change tracking control. + /// + /// The entity type, which must be a class implementing . + /// + /// Typically used with ORM providers like Entity Framework Core that support automatic change tracking. + /// public interface IGraphRepository : ILinqRepository where TEntity : class, IBusinessEntity { - + /// + /// Gets or sets whether entity change tracking is enabled for this repository. + /// + /// + /// When set to false, queries return detached entities for better read performance. + /// public bool Tracking { get; set; } } } diff --git a/Src/RCommon.Persistence/Crud/ILinqRepository.cs b/Src/RCommon.Persistence/Crud/ILinqRepository.cs index 27471a09..5c95be21 100644 --- a/Src/RCommon.Persistence/Crud/ILinqRepository.cs +++ b/Src/RCommon.Persistence/Crud/ILinqRepository.cs @@ -11,24 +11,89 @@ namespace RCommon.Persistence.Crud { - public interface ILinqRepository: IQueryable, IReadOnlyRepository, IWriteOnlyRepository, + /// + /// A LINQ-enabled repository that combines read, write, queryable, and eager loading capabilities + /// for entities of type . + /// + /// The entity type, which must implement . + /// + /// This interface extends , allowing repositories to be used directly + /// in LINQ queries. For graph/change-tracked scenarios, see . + /// + public interface ILinqRepository: IQueryable, IReadOnlyRepository, IWriteOnlyRepository, IEagerLoadableQueryable where TEntity : IBusinessEntity { + /// + /// Returns a queryable filtered by the given specification. + /// + /// The specification to filter entities. + /// An representing the filtered query. IQueryable FindQuery(ISpecification specification); + + /// + /// Returns a queryable filtered by the given expression. + /// + /// A lambda expression to filter entities. + /// An representing the filtered query. IQueryable FindQuery(Expression> expression); + + /// + /// Returns a queryable filtered, ordered, and paged by the given parameters. + /// + /// A lambda expression to filter entities. + /// An expression specifying the ordering property. + /// true for ascending order; false for descending. + /// The 1-based page number (default is 1). + /// The page size; 0 means no paging (default is 0). + /// An representing the filtered, ordered, and paged query. IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0); + + /// + /// Returns a queryable filtered and paged by the given paged specification. + /// + /// The paged specification containing filter, ordering, and paging criteria. + /// An representing the filtered and paged query. IQueryable FindQuery(IPagedSpecification specification); + /// + /// Returns a queryable filtered and ordered by the given parameters (without paging). + /// + /// A lambda expression to filter entities. + /// An expression specifying the ordering property. + /// true for ascending order; false for descending. + /// An representing the filtered and ordered query. IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending); + /// + /// Finds entities matching the expression with ordering and paging, returning a paginated list. + /// + /// A lambda expression to filter entities. + /// An expression specifying the ordering property. + /// true for ascending order; false for descending. + /// The 1-based page number (default is 1). + /// The page size; 0 means no paging (default is 0). + /// A cancellation token to observe. + /// A paginated list of matching entities. Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default); + + /// + /// Finds entities matching the paged specification, returning a paginated list. + /// + /// The paged specification containing filter, ordering, and paging criteria. + /// A cancellation token to observe. + /// A paginated list of matching entities. Task> FindAsync(IPagedSpecification specification, CancellationToken token = default); + /// + /// Eagerly loads a related navigation property specified by the expression. + /// + /// An expression specifying the navigation property to include. + /// An for further chained includes via ThenInclude. IEagerLoadableQueryable Include(Expression> path); } } diff --git a/Src/RCommon.Persistence/Crud/IReadOnlyRepository.cs b/Src/RCommon.Persistence/Crud/IReadOnlyRepository.cs index c3f97648..ce9ee9e9 100644 --- a/Src/RCommon.Persistence/Crud/IReadOnlyRepository.cs +++ b/Src/RCommon.Persistence/Crud/IReadOnlyRepository.cs @@ -10,24 +10,86 @@ namespace RCommon.Persistence.Crud { + /// + /// Defines read-only repository operations for querying entities of type . + /// + /// The entity type to query. + /// + /// This interface provides async query methods using both and + /// lambda expressions. See for write operations. + /// public interface IReadOnlyRepository : INamedDataSource { - + /// + /// Finds all entities matching the given specification. + /// + /// The specification to filter entities. + /// A cancellation token to observe. + /// A collection of entities satisfying the specification. Task> FindAsync(ISpecification specification, CancellationToken token = default); + + /// + /// Finds all entities matching the given expression. + /// + /// A lambda expression to filter entities. + /// A cancellation token to observe. + /// A collection of entities satisfying the expression. Task> FindAsync(Expression> expression, CancellationToken token = default); + /// + /// Finds a single entity by its primary key. + /// + /// The primary key value of the entity to find. + /// A cancellation token to observe. + /// The entity if found; otherwise, the default value for . Task FindAsync(object primaryKey, CancellationToken token = default); + /// + /// Gets the count of entities matching the given specification. + /// + /// The specification to filter entities. + /// A cancellation token to observe. + /// The number of matching entities. Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default); + /// + /// Gets the count of entities matching the given expression. + /// + /// A lambda expression to filter entities. + /// A cancellation token to observe. + /// The number of matching entities. Task GetCountAsync(Expression> expression, CancellationToken token = default); + /// + /// Finds a single entity matching the expression, or returns the default value if none is found. + /// + /// A lambda expression to filter entities. + /// A cancellation token to observe. + /// The matching entity or the default value for . Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default); + /// + /// Finds a single entity matching the specification, or returns the default value if none is found. + /// + /// The specification to filter entities. + /// A cancellation token to observe. + /// The matching entity or the default value for . Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default); + /// + /// Determines whether any entity matches the given expression. + /// + /// A lambda expression to filter entities. + /// A cancellation token to observe. + /// true if at least one entity matches; otherwise, false. Task AnyAsync(Expression> expression, CancellationToken token = default); + /// + /// Determines whether any entity matches the given specification. + /// + /// The specification to filter entities. + /// A cancellation token to observe. + /// true if at least one entity matches; otherwise, false. Task AnyAsync(ISpecification specification, CancellationToken token = default); } } diff --git a/Src/RCommon.Persistence/Crud/ISqlMapperRepository.cs b/Src/RCommon.Persistence/Crud/ISqlMapperRepository.cs index a94ac4ca..cdb145d5 100644 --- a/Src/RCommon.Persistence/Crud/ISqlMapperRepository.cs +++ b/Src/RCommon.Persistence/Crud/ISqlMapperRepository.cs @@ -11,10 +11,22 @@ namespace RCommon.Persistence.Crud { + /// + /// A repository that provides SQL-mapped (micro-ORM) CRUD operations for entities of type . + /// + /// The entity type, which must implement . + /// + /// Intended for use with lightweight data mappers such as Dapper, where entities are mapped + /// directly to database tables via the property. + /// Uses for database connectivity. + /// public interface ISqlMapperRepository : IReadOnlyRepository, IWriteOnlyRepository where TEntity : IBusinessEntity { + /// + /// Gets or sets the database table name that this repository maps to. + /// public string TableName { get; set; } - + } } diff --git a/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs b/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs index 732c4407..371587fd 100644 --- a/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs +++ b/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs @@ -9,6 +9,13 @@ namespace RCommon.Persistence.Crud { + /// + /// Defines write-only repository operations for persisting entities of type . + /// + /// The entity type to persist. + /// + /// This interface provides async CRUD write methods. See for read operations. + /// public interface IWriteOnlyRepository : INamedDataSource { diff --git a/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs b/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs index 76cb9bae..a4c80c5d 100644 --- a/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs +++ b/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs @@ -16,13 +16,33 @@ namespace RCommon.Persistence.Crud { + /// + /// Abstract base class for LINQ-enabled repositories that provides common queryable infrastructure, + /// event tracking, and data store resolution for entities of type . + /// + /// The entity type, which must implement . + /// + /// This class implements by delegating to the abstract + /// property, which concrete implementations must provide. It also handles default data store name assignment + /// from . + /// public abstract class LinqRepositoryBase : DisposableResource, ILinqRepository where TEntity : IBusinessEntity { - private string _dataStoreName; + private string _dataStoreName = default!; private readonly IDataStoreFactory _dataStoreFactory; - public LinqRepositoryBase(IDataStoreFactory dataStoreFactory, + /// + /// Initializes a new instance of the class. + /// + /// The factory used to resolve named data stores. + /// The entity event tracker for publishing domain events. + /// Options specifying the default data store name. + /// + /// Thrown when , , or + /// is null. + /// + public LinqRepositoryBase(IDataStoreFactory dataStoreFactory, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) { if (defaultDataStoreOptions is null) @@ -32,6 +52,7 @@ public LinqRepositoryBase(IDataStoreFactory dataStoreFactory, _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); EventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + // Apply default data store name if configured, so repositories work without explicit name assignment if (defaultDataStoreOptions != null && defaultDataStoreOptions.Value != null && !defaultDataStoreOptions.Value.DefaultDataStoreName.IsNullOrEmpty()) { @@ -121,40 +142,93 @@ public IEnumerable Query(ISpecification specification) return RepositoryQuery.Where(specification.Predicate).AsQueryable(); } + /// public abstract IQueryable FindQuery(ISpecification specification); + + /// public abstract IQueryable FindQuery(Expression> expression); + + /// public abstract IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending); + + /// public abstract Task AddAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task AddRangeAsync(IEnumerable entities, CancellationToken token = default); + + /// public abstract Task DeleteAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task DeleteManyAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task DeleteManyAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task UpdateAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task> FindAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task> FindAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindAsync(object primaryKey, CancellationToken token = default); + + /// public abstract Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default); + + /// public abstract Task GetCountAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task AnyAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task AnyAsync(ISpecification specification, CancellationToken token = default); + /// public abstract Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default); + + /// public abstract Task> FindAsync(IPagedSpecification specification, CancellationToken token = default); + /// public abstract IQueryable FindQuery(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0); + + /// public abstract IQueryable FindQuery(IPagedSpecification specification); + /// public abstract IEagerLoadableQueryable Include(Expression> path); + /// public abstract IEagerLoadableQueryable ThenInclude(Expression> path); - public ILogger Logger { get; set; } + + /// + /// Gets or sets the logger instance for this repository. + /// + public ILogger Logger { get; set; } = default!; + + /// + /// Gets the entity event tracker used to track and publish domain events raised by entities. + /// public IEntityEventTracker EventTracker { get; } + + /// public string DataStoreName { get => _dataStoreName; diff --git a/Src/RCommon.Persistence/Crud/RepositoryException.cs b/Src/RCommon.Persistence/Crud/RepositoryException.cs index 2aca9523..e0fcedda 100644 --- a/Src/RCommon.Persistence/Crud/RepositoryException.cs +++ b/Src/RCommon.Persistence/Crud/RepositoryException.cs @@ -4,8 +4,20 @@ namespace RCommon.Persistence.Crud { + /// + /// Exception thrown when a repository operation fails. + /// + /// + /// This wraps provider-specific exceptions (e.g., database constraint violations) at the repository layer. + /// See also for general persistence-level errors. + /// public class RepositoryException : ApplicationException { + /// + /// Initializes a new instance of the class. + /// + /// A message describing the repository operation failure. + /// The inner exception that caused this error. public RepositoryException(string message, Exception innerException) : base(message, innerException) { } diff --git a/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs b/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs index 4e6dc1fe..fe79638d 100644 --- a/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs +++ b/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs @@ -17,14 +17,33 @@ namespace RCommon.Persistence.Crud { + /// + /// Abstract base class for SQL-mapped (micro-ORM) repositories that provides common infrastructure + /// for data store resolution, event tracking, and CRUD operations for entities of type . + /// + /// The entity type, which must be a class implementing . + /// + /// Concrete implementations (e.g., Dapper repositories) should inherit from this class and provide + /// the actual SQL-based data access logic. Uses for database connectivity. + /// public abstract class SqlRepositoryBase : DisposableResource, ISqlMapperRepository where TEntity : class, IBusinessEntity { - private string _dataStoreName; + private string _dataStoreName = default!; private readonly IDataStoreFactory _dataStoreFactory; - public SqlRepositoryBase(IDataStoreFactory dataStoreFactory, - ILoggerFactory logger, IEntityEventTracker eventTracker, + /// + /// Initializes a new instance of the class. + /// + /// The factory used to resolve named data stores. + /// The logger factory for creating loggers. + /// The entity event tracker for publishing domain events. + /// Options specifying the default data store name. + /// + /// Thrown when any of the required parameters is null. + /// + public SqlRepositoryBase(IDataStoreFactory dataStoreFactory, + ILoggerFactory logger, IEntityEventTracker eventTracker, IOptions defaultDataStoreOptions) { if (logger is null) @@ -40,32 +59,66 @@ public SqlRepositoryBase(IDataStoreFactory dataStoreFactory, _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); EventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); - if (defaultDataStoreOptions != null && defaultDataStoreOptions.Value != null + // Apply default data store name if configured, so repositories work without explicit name assignment + if (defaultDataStoreOptions != null && defaultDataStoreOptions.Value != null && !defaultDataStoreOptions.Value.DefaultDataStoreName.IsNullOrEmpty()) { this.DataStoreName = defaultDataStoreOptions.Value.DefaultDataStoreName; } } + /// + public string TableName { get; set; } = default!; - public string TableName { get; set; } - + /// public abstract Task AddAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task AddRangeAsync(IEnumerable entities, CancellationToken token = default); + + /// public abstract Task DeleteAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task DeleteManyAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task DeleteManyAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task UpdateAsync(TEntity entity, CancellationToken token = default); + + /// public abstract Task> FindAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task> FindAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindAsync(object primaryKey, CancellationToken token = default); + + /// public abstract Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default); + + /// public abstract Task GetCountAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default); + + /// public abstract Task AnyAsync(Expression> expression, CancellationToken token = default); + + /// public abstract Task AnyAsync(ISpecification specification, CancellationToken token = default); + /// + /// Gets the resolved data store for this repository, + /// using the current to look up the connection from the factory. + /// protected internal RDbConnection DataStore { get @@ -74,6 +127,7 @@ protected internal RDbConnection DataStore } } + /// public string DataStoreName { get => _dataStoreName; @@ -83,7 +137,14 @@ public string DataStoreName } } - public ILogger Logger { get; set; } + /// + /// Gets or sets the logger instance for this repository. + /// + public ILogger Logger { get; set; } = default!; + + /// + /// Gets the entity event tracker used to track and publish domain events raised by entities. + /// public IEntityEventTracker EventTracker { get; } } diff --git a/Src/RCommon.Persistence/DataStoreFactory.cs b/Src/RCommon.Persistence/DataStoreFactory.cs index 74f970a4..71f844d2 100644 --- a/Src/RCommon.Persistence/DataStoreFactory.cs +++ b/Src/RCommon.Persistence/DataStoreFactory.cs @@ -10,22 +10,33 @@ namespace RCommon.Persistence { + /// + /// Default implementation of that resolves named data stores + /// from the DI container based on registered entries. + /// public class DataStoreFactory : IDataStoreFactory { private readonly IServiceProvider _provider; private readonly ConcurrentBag _values; + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve data store instances. + /// The configured factory options containing registered entries. public DataStoreFactory(IServiceProvider provider, IOptions options) { _provider = provider; _values = options.Value.Values; } + /// public C Resolve(string name) where B : IDataStore where C : B, IDataStore { - DataStoreValue value = new DataStoreValue(name, typeof(B), typeof(C)); + // Attempt to peek at the first value in the bag as a quick existence check + DataStoreValue? value = new DataStoreValue(name, typeof(B), typeof(C)); if (_values.TryPeek(out value)) { return (C)_provider.GetRequiredService(value.ConcreteType); @@ -34,9 +45,11 @@ public C Resolve(string name) throw new DataStoreNotFoundException($"DataStore with name of {name} not found"); } + /// public B Resolve(string name) where B : IDataStore { + // Search the registered values by both name and base type to find the matching concrete type if (_values.Any(x => x.Name == name && x.BaseType == typeof(B))) { return (B)_provider.GetRequiredService(_values.First(x => x.Name == name && x.BaseType == typeof(B)).ConcreteType); diff --git a/Src/RCommon.Persistence/DataStoreFactoryOptions.cs b/Src/RCommon.Persistence/DataStoreFactoryOptions.cs index df0b174b..49701521 100644 --- a/Src/RCommon.Persistence/DataStoreFactoryOptions.cs +++ b/Src/RCommon.Persistence/DataStoreFactoryOptions.cs @@ -8,14 +8,31 @@ namespace RCommon.Persistence { + /// + /// Configuration options for that holds the collection of registered + /// entries used to resolve data stores by name and type. + /// public class DataStoreFactoryOptions { + /// + /// Gets the thread-safe collection of registered entries. + /// public ConcurrentBag Values { get; } = new ConcurrentBag(); + /// + /// Registers a data store mapping with the specified name, base type, and concrete type. + /// + /// The base data store type (e.g., a provider-specific DbContext base). + /// The concrete data store type that implements . + /// A unique name identifying the data store registration. + /// + /// Thrown when a data store with the same and base type is already registered. + /// public void Register(string name) where B : IDataStore where C : IDataStore { + // Prevent duplicate registrations with the same name and base type if (!Values.Any(x => x.Name == name && x.BaseType == typeof(B))) { Values.Add(new DataStoreValue(name, typeof(B), typeof(C))); diff --git a/Src/RCommon.Persistence/DataStoreNotFoundException.cs b/Src/RCommon.Persistence/DataStoreNotFoundException.cs index 2efc76af..2104a890 100644 --- a/Src/RCommon.Persistence/DataStoreNotFoundException.cs +++ b/Src/RCommon.Persistence/DataStoreNotFoundException.cs @@ -6,8 +6,15 @@ namespace RCommon.Persistence { + /// + /// Exception thrown when a named data store cannot be found in the registry. + /// public class DataStoreNotFoundException : GeneralException { + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// A message describing which data store could not be found. public DataStoreNotFoundException(string message) : base(message) { diff --git a/Src/RCommon.Persistence/DataStoreValue.cs b/Src/RCommon.Persistence/DataStoreValue.cs index 360c9943..c0133945 100644 --- a/Src/RCommon.Persistence/DataStoreValue.cs +++ b/Src/RCommon.Persistence/DataStoreValue.cs @@ -6,22 +6,47 @@ namespace RCommon.Persistence { + /// + /// Represents a named data store registration that maps a base type to a concrete type, + /// used by to resolve data stores from the DI container. + /// public class DataStoreValue { + /// + /// Initializes a new instance of the class. + /// + /// The unique name identifying this data store registration. + /// The base type (e.g., a provider-specific DbContext base class). + /// The concrete type that directly inherits from . + /// + /// Thrown when does not directly inherit from . + /// public DataStoreValue(string name, Type baseType, Type concreteType) { Name = name; BaseType = baseType; ConcreteType = concreteType; + // Validate that the concrete type directly inherits from the base type if (concreteType.BaseType != baseType) { throw new UnsupportedDataStoreException($"Concrete type must implement base type."); } } + /// + /// Gets the unique name identifying this data store registration. + /// public string Name { get; } + + /// + /// Gets the base type used for resolving this data store. + /// public Type BaseType { get; } + + /// + /// Gets the concrete type that will be resolved from the DI container. + /// public Type ConcreteType { get; } } } diff --git a/Src/RCommon.Persistence/DefaultDataStoreOptions.cs b/Src/RCommon.Persistence/DefaultDataStoreOptions.cs index ce67f76f..7c149999 100644 --- a/Src/RCommon.Persistence/DefaultDataStoreOptions.cs +++ b/Src/RCommon.Persistence/DefaultDataStoreOptions.cs @@ -6,13 +6,23 @@ namespace RCommon { + /// + /// Options for configuring the default data store name that repositories will use + /// when no explicit is specified. + /// public class DefaultDataStoreOptions { + /// + /// Initializes a new instance of the class. + /// public DefaultDataStoreOptions() { } - public string DefaultDataStoreName { get; set; } + /// + /// Gets or sets the name of the default data store to be used by repositories. + /// + public string DefaultDataStoreName { get; set; } = default!; } } diff --git a/Src/RCommon.Persistence/IDataStore.cs b/Src/RCommon.Persistence/IDataStore.cs index 17d945be..125a2b14 100644 --- a/Src/RCommon.Persistence/IDataStore.cs +++ b/Src/RCommon.Persistence/IDataStore.cs @@ -7,8 +7,19 @@ namespace RCommon.Persistence { + /// + /// Represents an abstraction over a data store (e.g., a database context or connection) that supports async disposal. + /// + /// + /// Implementations of this interface wrap provider-specific data access contexts such as EF Core DbContext + /// or ADO.NET connections. See for a concrete ADO.NET implementation. + /// public interface IDataStore : IAsyncDisposable - { + { + /// + /// Gets the underlying associated with this data store. + /// + /// A instance that can be used for direct database operations. DbConnection GetDbConnection(); } diff --git a/Src/RCommon.Persistence/INamedDataSource.cs b/Src/RCommon.Persistence/INamedDataSource.cs index ad4b5c59..bdfc0df7 100644 --- a/Src/RCommon.Persistence/INamedDataSource.cs +++ b/Src/RCommon.Persistence/INamedDataSource.cs @@ -6,8 +6,18 @@ namespace RCommon.Persistence { + /// + /// Indicates that a component (such as a repository) is associated with a named data source, + /// allowing it to be resolved from a by name. + /// public interface INamedDataSource { + /// + /// Gets or sets the name of the data store this component is associated with. + /// + /// + /// This name is used by to resolve the correct instance. + /// public string DataStoreName { get; set; } } } diff --git a/Src/RCommon.Persistence/IPersistenceBuilder.cs b/Src/RCommon.Persistence/IPersistenceBuilder.cs index 740a897f..a88c1793 100644 --- a/Src/RCommon.Persistence/IPersistenceBuilder.cs +++ b/Src/RCommon.Persistence/IPersistenceBuilder.cs @@ -6,9 +6,22 @@ namespace RCommon /// /// Base interface implemented by specific data configurators that configure RCommon data providers. /// + /// + /// Concrete implementations (e.g., EF Core, Dapper, MongoDB builders) register provider-specific services + /// and data stores into the DI container via the collection. + /// public interface IPersistenceBuilder { + /// + /// Sets the default data store that repositories will use when no explicit data store name is specified. + /// + /// An action to configure the . + /// The current instance for fluent chaining. IPersistenceBuilder SetDefaultDataStore(Action options); + + /// + /// Gets the used to register persistence-related services. + /// IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Persistence/IReadModel.cs b/Src/RCommon.Persistence/IReadModel.cs index 3356dede..590457a1 100644 --- a/Src/RCommon.Persistence/IReadModel.cs +++ b/Src/RCommon.Persistence/IReadModel.cs @@ -6,6 +6,13 @@ namespace RCommon.Persistence { + /// + /// Marker interface for read model entities used in CQRS-style query projections. + /// + /// + /// Implementing this interface signals that the entity is intended for read-only query scenarios + /// and should not be used for write (command) operations. + /// public interface IReadModel { } diff --git a/Src/RCommon.Persistence/IScopedDataStore.cs b/Src/RCommon.Persistence/IScopedDataStore.cs index da8b4fd9..c5724355 100644 --- a/Src/RCommon.Persistence/IScopedDataStore.cs +++ b/Src/RCommon.Persistence/IScopedDataStore.cs @@ -3,8 +3,15 @@ namespace RCommon.Persistence { + /// + /// Represents a scoped registry of data stores, allowing multiple named data store types to be tracked + /// within a single scope (e.g., a request or unit of work). + /// public interface IScopedDataStore { + /// + /// Gets or sets the thread-safe dictionary that maps data store names to their corresponding entries. + /// ConcurrentDictionary DataStores { get; set; } } } \ No newline at end of file diff --git a/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs b/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs index 6b179962..11161cdc 100644 --- a/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs +++ b/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs @@ -13,27 +13,51 @@ namespace RCommon { + /// + /// Extension methods for that register persistence providers and unit of work services. + /// public static class PersistenceBuilderExtensions { + /// + /// Adds a persistence provider with default configuration. + /// + /// The implementation to configure (e.g., EF Core, Dapper). + /// The RCommon builder instance. + /// The for fluent chaining. public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder) where TObjectAccess : IPersistenceBuilder { return WithPersistence(builder, x => { }); } + /// + /// Adds a persistence provider and applies the specified configuration actions. + /// + /// The implementation to configure. + /// The RCommon builder instance. + /// An action to configure the persistence provider (e.g., register data stores). + /// The for fluent chaining. public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder, Action objectAccessActions) where TObjectAccess : IPersistenceBuilder { - var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services }); + // Create the persistence builder via Activator since the concrete type is not known at compile time + var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!; objectAccessActions(dataConfiguration); builder = WithEventTracking(builder); return builder; } + /// + /// Adds a unit of work implementation and applies the specified configuration actions. + /// + /// The implementation to configure. + /// The RCommon builder instance. + /// An action to configure the unit of work (e.g., set isolation level, auto-complete). + /// The for fluent chaining. public static IRCommonBuilder WithUnitOfWork(this IRCommonBuilder builder, Action unitOfWorkActions) where TUnitOfWork : IUnitOfWorkBuilder { - var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services }); + var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!; unitOfWorkActions(unitOfWorkConfiguration); return builder; } @@ -120,9 +144,9 @@ public static IRCommonBuilder WithPersistence(this I where TObjectAccess : IPersistenceBuilder where TUnitOfWork : IUnitOfWorkBuilder { - var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services }); + var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!; objectAccessActions(dataConfiguration); - var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services }); + var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!; unitOfWorkActions(unitOfWorkConfiguration); builder = WithEventTracking(builder); return builder; diff --git a/Src/RCommon.Persistence/PersistenceException.cs b/Src/RCommon.Persistence/PersistenceException.cs index 9b2ae7b8..872f0a2b 100644 --- a/Src/RCommon.Persistence/PersistenceException.cs +++ b/Src/RCommon.Persistence/PersistenceException.cs @@ -6,11 +6,23 @@ namespace RCommon.Persistence { + /// + /// Exception thrown when a general persistence operation fails. + /// + /// + /// This wraps provider-specific exceptions with a persistence-layer abstraction. + /// See also for repository-specific errors. + /// public class PersistenceException : GeneralException { + /// + /// Initializes a new instance of the class. + /// + /// A message describing the persistence failure. + /// The inner exception that caused this persistence error. public PersistenceException(string message, Exception exception) : base(message, exception) { - + } } } diff --git a/Src/RCommon.Persistence/RCommon.Persistence.csproj b/Src/RCommon.Persistence/RCommon.Persistence.csproj index d0b8dc24..585f9c08 100644 --- a/Src/RCommon.Persistence/RCommon.Persistence.csproj +++ b/Src/RCommon.Persistence/RCommon.Persistence.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Persistence https://rcommon.com diff --git a/Src/RCommon.Persistence/README.md b/Src/RCommon.Persistence/README.md index 29dd4641..57c0de6d 100644 --- a/Src/RCommon.Persistence/README.md +++ b/Src/RCommon.Persistence/README.md @@ -1,3 +1,99 @@ - # RCommon.Persistence +# RCommon.Persistence -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Persistence abstraction layer for RCommon providing the repository pattern, unit of work, specification pattern, and named data store management. This package defines the core interfaces and base classes that ORM-specific implementations (EF Core, Dapper, Linq2Db) build upon. + +## Features + +- Repository pattern with separate read-only and write-only interfaces for CQRS-friendly designs +- LINQ-enabled repositories exposing `IQueryable` for composable queries +- Graph repositories with change tracking support for ORMs like Entity Framework Core +- SQL mapper repositories for micro-ORMs like Dapper +- Specification pattern support for encapsulating query logic +- Paginated query results via `IPaginatedList` with built-in ordering +- Eager loading abstraction with `Include` / `ThenInclude` chaining +- Unit of work pattern with configurable transaction modes and isolation levels +- Named data store factory for managing multiple database connections +- Domain event tracking integrated into repository operations +- Fluent builder API for DI registration via `AddRCommon()` + +## Installation + +```shell +dotnet add package RCommon.Persistence +``` + +## Usage + +This package is typically used indirectly through a provider-specific package. However, you program against these abstractions in your application and domain layers: + +```csharp +// Inject repository abstractions into your services +public class OrderService +{ + private readonly IGraphRepository _orderRepo; + private readonly IUnitOfWorkFactory _unitOfWorkFactory; + + public OrderService(IGraphRepository orderRepo, IUnitOfWorkFactory unitOfWorkFactory) + { + _orderRepo = orderRepo; + _unitOfWorkFactory = unitOfWorkFactory; + } + + public async Task GetOrderAsync(int id) + { + return await _orderRepo.FindAsync(id); + } + + public async Task> GetPendingOrdersAsync() + { + return await _orderRepo.FindAsync(o => o.Status == OrderStatus.Pending); + } + + public async Task> GetOrdersPagedAsync(int page, int pageSize) + { + return await _orderRepo.FindAsync( + o => o.IsActive, + o => o.CreatedDate, + orderByAscending: false, + pageNumber: page, + pageSize: pageSize); + } + + public async Task PlaceOrderAsync(Order order) + { + using var unitOfWork = _unitOfWorkFactory.Create(TransactionMode.Default); + await _orderRepo.AddAsync(order); + unitOfWork.Commit(); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `IReadOnlyRepository` | Async read operations: find by key, expression, specification, count, and any | +| `IWriteOnlyRepository` | Async write operations: add, add range, update, delete, and delete many | +| `ILinqRepository` | Combines read/write with `IQueryable` support, pagination, and eager loading | +| `IGraphRepository` | Extends `ILinqRepository` with change tracking control for full ORMs | +| `ISqlMapperRepository` | Read/write repository for micro-ORMs with explicit table name mapping | +| `IUnitOfWork` | Transaction scope that commits or rolls back on dispose | +| `IUnitOfWorkFactory` | Creates `IUnitOfWork` instances with configurable transaction mode and isolation level | +| `IDataStoreFactory` | Resolves named data store instances (DbContext, DbConnection, etc.) | +| `IPersistenceBuilder` | Fluent builder interface for registering persistence providers in DI | +| `LinqRepositoryBase` | Abstract base class for LINQ-enabled repository implementations | +| `SqlRepositoryBase` | Abstract base class for SQL mapper repository implementations | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.EFCore](https://www.nuget.org/packages/RCommon.EFCore) - Entity Framework Core implementation +- [RCommon.Dapper](https://www.nuget.org/packages/RCommon.Dapper) - Dapper implementation +- [RCommon.Linq2Db](https://www.nuget.org/packages/RCommon.Linq2Db) - Linq2Db implementation + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Persistence/Sql/IRDbConnection.cs b/Src/RCommon.Persistence/Sql/IRDbConnection.cs index 4e2861b9..2bba0f34 100644 --- a/Src/RCommon.Persistence/Sql/IRDbConnection.cs +++ b/Src/RCommon.Persistence/Sql/IRDbConnection.cs @@ -4,8 +4,14 @@ namespace RCommon.Persistence.Sql { + /// + /// Represents an ADO.NET-based data store that extends to provide + /// raw database connection access for SQL-mapper repositories. + /// + /// + /// public interface IRDbConnection : IDataStore { - + } } diff --git a/Src/RCommon.Persistence/Sql/RDbConnection.cs b/Src/RCommon.Persistence/Sql/RDbConnection.cs index a1667765..673b0232 100644 --- a/Src/RCommon.Persistence/Sql/RDbConnection.cs +++ b/Src/RCommon.Persistence/Sql/RDbConnection.cs @@ -11,26 +11,46 @@ namespace RCommon.Persistence.Sql { + /// + /// Default implementation of that creates ADO.NET + /// instances using a configured and connection string. + /// + /// + /// This class uses the options pattern to obtain connection configuration from . + /// Each call to creates a new connection instance. + /// public class RDbConnection : DisposableResource, IRDbConnection { private readonly IOptions _options; - private readonly IEntityEventTracker _entityEventTracker; + /// + /// Initializes a new instance of the class. + /// + /// The connection options containing the and connection string. + /// Thrown when is null. public RDbConnection(IOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); } + /// + /// Creates and returns a new using the configured provider factory and connection string. + /// + /// A new with its connection string set. + /// + /// Thrown when options, the provider factory, or the connection string is not properly configured. + /// public DbConnection GetDbConnection() { + // Validate all configuration requirements before creating the connection Guard.Against(this._options == null, "No options configured for this RDbConnection"); - Guard.Against(this._options.Value == null, "No options configured for this RDbConnection"); - Guard.Against(this._options.Value.DbFactory == null, "You must configured a DbProviderFactory for this RDbConnection"); + Guard.Against(this._options!.Value == null, "No options configured for this RDbConnection"); + Guard.Against(this._options.Value!.DbFactory == null, "You must configured a DbProviderFactory for this RDbConnection"); Guard.Against(this._options.Value.ConnectionString.IsNullOrEmpty(), "You must configure a conneciton string for this RDbConnection"); - var connection = this._options.Value.DbFactory.CreateConnection(); - connection.ConnectionString = this._options.Value.ConnectionString; - + var connection = this._options.Value.DbFactory!.CreateConnection(); + connection!.ConnectionString = this._options.Value.ConnectionString; + return connection; } diff --git a/Src/RCommon.Persistence/Sql/RDbConnectionException.cs b/Src/RCommon.Persistence/Sql/RDbConnectionException.cs index 47c46831..1e212326 100644 --- a/Src/RCommon.Persistence/Sql/RDbConnectionException.cs +++ b/Src/RCommon.Persistence/Sql/RDbConnectionException.cs @@ -6,10 +6,16 @@ namespace RCommon.Persistence.Sql { + /// + /// Exception thrown when an encounters a configuration or connectivity error. + /// public class RDbConnectionException : GeneralException { - - public RDbConnectionException(string message) + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// A message describing the connection error. + public RDbConnectionException(string message) : base(message) { diff --git a/Src/RCommon.Persistence/Sql/RDbConnectionOptions.cs b/Src/RCommon.Persistence/Sql/RDbConnectionOptions.cs index 6b3ba7ab..7d2658a3 100644 --- a/Src/RCommon.Persistence/Sql/RDbConnectionOptions.cs +++ b/Src/RCommon.Persistence/Sql/RDbConnectionOptions.cs @@ -7,14 +7,27 @@ namespace RCommon.Persistence.Sql { + /// + /// Configuration options for , specifying the provider factory and connection string. + /// public class RDbConnectionOptions { + /// + /// Initializes a new instance of the class. + /// public RDbConnectionOptions() { } - public DbProviderFactory DbFactory { get; set; } - public string ConnectionString { get; set; } + /// + /// Gets or sets the used to create instances. + /// + public DbProviderFactory DbFactory { get; set; } = default!; + + /// + /// Gets or sets the database connection string. + /// + public string ConnectionString { get; set; } = default!; } } diff --git a/Src/RCommon.Persistence/Transactions/DefaultUnitOfWorkBuilder.cs b/Src/RCommon.Persistence/Transactions/DefaultUnitOfWorkBuilder.cs index 04ca917c..8952010c 100644 --- a/Src/RCommon.Persistence/Transactions/DefaultUnitOfWorkBuilder.cs +++ b/Src/RCommon.Persistence/Transactions/DefaultUnitOfWorkBuilder.cs @@ -27,12 +27,18 @@ namespace RCommon.Persistence.Transactions { /// - /// Implementation of . + /// Default implementation of that registers + /// and into the DI container. /// public class DefaultUnitOfWorkBuilder : IUnitOfWorkBuilder { private readonly IServiceCollection _services; + /// + /// Initializes a new instance of the class and registers + /// unit of work services as transient in the DI container. + /// + /// The service collection to register services into. public DefaultUnitOfWorkBuilder(IServiceCollection services) { @@ -42,6 +48,7 @@ public DefaultUnitOfWorkBuilder(IServiceCollection services) _services = services; } + /// public IUnitOfWorkBuilder SetOptions(Action unitOfWorkOptions) { _services.Configure(unitOfWorkOptions); diff --git a/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs b/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs index d78b940a..93eb218f 100644 --- a/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs +++ b/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs @@ -4,14 +4,47 @@ namespace RCommon.Persistence.Transactions { + /// + /// Defines a unit of work that manages a transaction scope around one or more persistence operations. + /// + /// + /// Disposing a unit of work without calling will result in a rollback. + /// Use to create instances with specific transaction settings. + /// public interface IUnitOfWork : IDisposable { + /// + /// Gets a value indicating whether the unit of work will automatically commit on disposal + /// if no explicit commit or rollback was attempted. + /// bool AutoComplete { get; } + + /// + /// Gets or sets the for the underlying transaction. + /// IsolationLevel IsolationLevel { get; set; } + + /// + /// Gets the current of this unit of work. + /// UnitOfWorkState State { get; } + + /// + /// Gets the unique identifier for this unit of work transaction. + /// Guid TransactionId { get; } + + /// + /// Gets or sets the that determines how this unit of work + /// participates in ambient transactions. + /// TransactionMode TransactionMode { get; set; } + /// + /// Commits the unit of work, completing the underlying transaction scope. + /// + /// Thrown if the unit of work has already been disposed. + /// Thrown if the unit of work has already been completed. void Commit(); } } diff --git a/Src/RCommon.Persistence/Transactions/IUnitOfWorkBuilder.cs b/Src/RCommon.Persistence/Transactions/IUnitOfWorkBuilder.cs index 8afe5c92..64052ff8 100644 --- a/Src/RCommon.Persistence/Transactions/IUnitOfWorkBuilder.cs +++ b/Src/RCommon.Persistence/Transactions/IUnitOfWorkBuilder.cs @@ -13,8 +13,18 @@ namespace RCommon.Persistence.Transactions { + /// + /// Builder interface for configuring unit of work services and default settings during application startup. + /// + /// + /// public interface IUnitOfWorkBuilder { + /// + /// Configures the default such as isolation level and auto-complete behavior. + /// + /// An action to configure the . + /// The current instance for fluent chaining. IUnitOfWorkBuilder SetOptions(Action unitOfWorkOptions); } } \ No newline at end of file diff --git a/Src/RCommon.Persistence/Transactions/IUnitOfWorkFactory.cs b/Src/RCommon.Persistence/Transactions/IUnitOfWorkFactory.cs index 20a6a296..36eec98a 100644 --- a/Src/RCommon.Persistence/Transactions/IUnitOfWorkFactory.cs +++ b/Src/RCommon.Persistence/Transactions/IUnitOfWorkFactory.cs @@ -3,10 +3,31 @@ namespace RCommon.Persistence.Transactions { + /// + /// Factory for creating instances with configurable transaction settings. + /// + /// public interface IUnitOfWorkFactory { + /// + /// Creates a new with default transaction settings. + /// + /// A new instance. IUnitOfWork Create(); + + /// + /// Creates a new with the specified transaction mode. + /// + /// The to use. + /// A new instance. IUnitOfWork Create(TransactionMode transactionMode); + + /// + /// Creates a new with the specified transaction mode and isolation level. + /// + /// The to use. + /// The for the transaction. + /// A new instance. IUnitOfWork Create(TransactionMode transactionMode, IsolationLevel isolationLevel); } } diff --git a/Src/RCommon.Persistence/Transactions/TransactionScopeHelper.cs b/Src/RCommon.Persistence/Transactions/TransactionScopeHelper.cs index 1943ef54..063738a4 100644 --- a/Src/RCommon.Persistence/Transactions/TransactionScopeHelper.cs +++ b/Src/RCommon.Persistence/Transactions/TransactionScopeHelper.cs @@ -31,6 +31,22 @@ namespace RCommon.Persistence.Transactions public static class TransactionScopeHelper { + /// + /// Creates a based on the + /// of the specified unit of work. + /// + /// The logger for diagnostic output about the created scope type. + /// The unit of work whose and + /// determine the scope configuration. + /// + /// A configured as follows: + /// + /// : with the specified isolation level. + /// : (no transaction). + /// : (joins existing or creates new). + /// + /// All scopes are created with for async support. + /// public static TransactionScope CreateScope(ILogger logger, IUnitOfWork unitOfWork) { if (unitOfWork.TransactionMode == TransactionMode.New) @@ -43,6 +59,7 @@ public static TransactionScope CreateScope(ILogger logger, IUnitOfWo logger.LogDebug("Creating a new TransactionScope with TransactionScopeOption.Supress"); return new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); } + // Default mode: join existing ambient transaction or create a new one logger.LogDebug("Creating a new TransactionScope with TransactionScopeOption.Required"); return new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled); } diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWork.cs b/Src/RCommon.Persistence/Transactions/UnitOfWork.cs index 5110f202..18733b68 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWork.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWork.cs @@ -10,6 +10,15 @@ namespace RCommon.Persistence.Transactions { + /// + /// Default implementation of that wraps a + /// to provide transactional consistency across persistence operations. + /// + /// + /// The unit of work transitions through the lifecycle: + /// Created -> CommitAttempted -> Completed -> Disposed, or Created -> RolledBack -> Disposed. + /// Disposing without committing will trigger either auto-complete (if enabled) or rollback. + /// public class UnitOfWork : DisposableResource, IUnitOfWork { private readonly ILogger _logger; @@ -17,6 +26,12 @@ public class UnitOfWork : DisposableResource, IUnitOfWork private UnitOfWorkState _state; private TransactionScope _transactionScope; + /// + /// Initializes a new instance of the class using configured settings. + /// + /// The logger for diagnostic output. + /// The GUID generator for creating the transaction identifier. + /// The configured settings for isolation level and auto-complete behavior. public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, IOptions unitOfWorkSettings) { _logger = logger; @@ -29,6 +44,14 @@ public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, IOpt _state = UnitOfWorkState.Created; _transactionScope = TransactionScopeHelper.CreateScope(_logger, this); } + + /// + /// Initializes a new instance of the class with explicit transaction settings. + /// + /// The logger for diagnostic output. + /// The GUID generator for creating the transaction identifier. + /// The transaction mode for this unit of work. + /// The isolation level for the underlying transaction. public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, TransactionMode transactionMode, IsolationLevel isolationLevel) { _logger = logger; @@ -42,6 +65,7 @@ public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, Tran _transactionScope = TransactionScopeHelper.CreateScope(_logger, this); } + /// public void Commit() { Guard.Against(_state == UnitOfWorkState.Disposed, @@ -53,17 +77,27 @@ public void Commit() this.Complete(); } + /// + /// Marks the unit of work as rolled back, preventing the transaction from being committed. + /// private void Rollback() { _state = UnitOfWorkState.RolledBack; } + /// + /// Completes the underlying , signaling that all operations succeeded. + /// private void Complete() { _transactionScope.Complete(); _state = UnitOfWorkState.Completed; } + /// + /// Disposes the unit of work, handling auto-complete or rollback based on current state. + /// + /// true if called from ; false if from a finalizer. protected override void Dispose(bool disposing) { if (_state == UnitOfWorkState.Disposed) @@ -104,11 +138,19 @@ protected override void Dispose(bool disposing) } } + /// public Guid TransactionId { get; } + + /// public TransactionMode TransactionMode { get; set; } + + /// public IsolationLevel IsolationLevel { get; set; } + + /// public bool AutoComplete { get; } + /// public UnitOfWorkState State => _state; } } diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWorkException.cs b/Src/RCommon.Persistence/Transactions/UnitOfWorkException.cs index e16df102..fd99dc57 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWorkException.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWorkException.cs @@ -6,8 +6,16 @@ namespace RCommon.Persistence.Transactions { + /// + /// Exception thrown when a operation fails, + /// such as attempting to commit an already completed or rolled-back scope. + /// public class UnitOfWorkException : GeneralException { + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// A message describing the unit of work failure. public UnitOfWorkException(string message) : base(message) { diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWorkFactory.cs b/Src/RCommon.Persistence/Transactions/UnitOfWorkFactory.cs index 9361bcfa..174d7136 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWorkFactory.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWorkFactory.cs @@ -8,34 +8,46 @@ namespace RCommon.Persistence.Transactions { + /// + /// Default implementation of that creates + /// instances from the DI container with optional transaction mode and isolation level overrides. + /// public class UnitOfWorkFactory : IUnitOfWorkFactory { private readonly IServiceProvider _serviceProvider; - private readonly IEventBus _eventBus; private readonly IGuidGenerator _guidGenerator; + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve instances. + /// The GUID generator (passed through to resolved unit of work instances). + /// Thrown when is null. public UnitOfWorkFactory(IServiceProvider serviceProvider, IGuidGenerator guidGenerator) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _guidGenerator = guidGenerator; } + /// public IUnitOfWork Create() { var unitOfWork = _serviceProvider.GetService(); - return unitOfWork; + return unitOfWork!; } + /// public IUnitOfWork Create(TransactionMode transactionMode) { - var unitOfWork = _serviceProvider.GetService(); + var unitOfWork = _serviceProvider.GetRequiredService(); unitOfWork.TransactionMode = transactionMode; return unitOfWork; } + /// public IUnitOfWork Create(TransactionMode transactionMode, IsolationLevel isolationLevel) { - var unitOfWork = _serviceProvider.GetService(); + var unitOfWork = _serviceProvider.GetRequiredService(); unitOfWork.TransactionMode = transactionMode; return unitOfWork; } diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWorkSettings.cs b/Src/RCommon.Persistence/Transactions/UnitOfWorkSettings.cs index 90d03865..e027c9c1 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWorkSettings.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWorkSettings.cs @@ -19,10 +19,18 @@ namespace RCommon.Persistence.Transactions { /// - /// Contains settings for RCommon unit of work. + /// Contains default settings for instances, including isolation level and auto-complete behavior. /// + /// + /// These settings are applied when a is created via DI with the default constructor. + /// Configure these settings using . + /// public class UnitOfWorkSettings { + /// + /// Initializes a new instance of the class + /// with and auto-complete disabled. + /// public UnitOfWorkSettings() { DefaultIsolation = IsolationLevel.ReadCommitted; diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWorkState.cs b/Src/RCommon.Persistence/Transactions/UnitOfWorkState.cs index 74616706..56543198 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWorkState.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWorkState.cs @@ -6,12 +6,34 @@ namespace RCommon.Persistence.Transactions { + /// + /// Represents the lifecycle state of a . + /// public enum UnitOfWorkState { + /// + /// The unit of work has been created but no commit or rollback has been attempted. + /// Created = 1, + + /// + /// A commit has been attempted on the unit of work. + /// CommitAttempted = 2, + + /// + /// The unit of work has been rolled back. + /// RolledBack = 3, + + /// + /// The unit of work has been successfully completed (committed). + /// Completed = 4, + + /// + /// The unit of work has been disposed and can no longer be used. + /// Disposed = 5 } } diff --git a/Src/RCommon.Persistence/UnsupportedDataStoreException.cs b/Src/RCommon.Persistence/UnsupportedDataStoreException.cs index bb59deeb..ce086e75 100644 --- a/Src/RCommon.Persistence/UnsupportedDataStoreException.cs +++ b/Src/RCommon.Persistence/UnsupportedDataStoreException.cs @@ -5,9 +5,19 @@ namespace RCommon.Persistence { + /// + /// Exception thrown when a data store registration or resolution is not supported, + /// such as registering a duplicate data store name or an invalid type hierarchy. + /// + /// + /// public class UnsupportedDataStoreException : GeneralException { + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// A message describing the unsupported data store operation. public UnsupportedDataStoreException(string message) :base(message) { diff --git a/Src/RCommon.RedisCache/IRedisCachingBuilder.cs b/Src/RCommon.RedisCache/IRedisCachingBuilder.cs index 963e861a..5ce19898 100644 --- a/Src/RCommon.RedisCache/IRedisCachingBuilder.cs +++ b/Src/RCommon.RedisCache/IRedisCachingBuilder.cs @@ -7,6 +7,13 @@ namespace RCommon.RedisCache { + /// + /// Marker interface for configuring Redis-backed distributed caching. + /// + /// + /// Extends to allow Redis-specific cache + /// configuration via extension methods in . + /// public interface IRedisCachingBuilder : IDistributedCachingBuilder { } diff --git a/Src/RCommon.RedisCache/IRedisCachingBuilderExtensions.cs b/Src/RCommon.RedisCache/IRedisCachingBuilderExtensions.cs index 32427920..8d1f975e 100644 --- a/Src/RCommon.RedisCache/IRedisCachingBuilderExtensions.cs +++ b/Src/RCommon.RedisCache/IRedisCachingBuilderExtensions.cs @@ -10,8 +10,18 @@ namespace RCommon.RedisCache { + /// + /// Extension methods for that configure + /// Redis cache options and expression caching. + /// public static class IRedisCachingBuilderExtensions { + /// + /// Configures the underlying for the StackExchange Redis cache. + /// + /// The Redis caching builder. + /// A delegate to configure . + /// The same for chaining. public static IRedisCachingBuilder Configure(this IRedisCachingBuilder builder, Action actions) { builder.Services.AddStackExchangeRedisCache(actions); @@ -19,7 +29,7 @@ public static IRedisCachingBuilder Configure(this IRedisCachingBuilder builder, } /// - /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily + /// This greatly improves performance across various areas of RCommon which use generics and reflection heavily /// to compile expressions and lambdas /// /// Builder @@ -35,17 +45,19 @@ public static IRedisCachingBuilder CacheDynamicallyCompiledExpressions(this IRed x.CachingEnabled = true; x.CacheDynamicallyCompiledExpressions = true; }); + + // Register factory that resolves the correct ICacheService based on the ExpressionCachingStrategy builder.Services.TryAddTransient>(serviceProvider => strategy => { switch (strategy) { case ExpressionCachingStrategy.Default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); default: - return serviceProvider.GetService(); + return serviceProvider.GetRequiredService(); } }); - + return builder; } } diff --git a/Src/RCommon.RedisCache/RCommon.RedisCache.csproj b/Src/RCommon.RedisCache/RCommon.RedisCache.csproj index 8582968f..e39b880e 100644 --- a/Src/RCommon.RedisCache/RCommon.RedisCache.csproj +++ b/Src/RCommon.RedisCache/RCommon.RedisCache.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.RedisCache https://rcommon.com diff --git a/Src/RCommon.RedisCache/README.md b/Src/RCommon.RedisCache/README.md index 30eef560..624aeee0 100644 --- a/Src/RCommon.RedisCache/README.md +++ b/Src/RCommon.RedisCache/README.md @@ -1,3 +1,59 @@ - # RCommon.RedisCache +# RCommon.RedisCache -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides a Redis-backed distributed cache implementation of `ICacheService` using `IDistributedCache` from StackExchange.Redis, with fluent builder extensions for DI configuration. + +## Features + +- `RedisCacheService` -- implements `ICacheService` using `IDistributedCache` backed by StackExchange.Redis +- Automatic JSON serialization/deserialization of cached values via `IJsonSerializer` +- `RedisCachingBuilder` for plugging into the `AddRCommon()` builder pipeline via `WithDistributedCaching` +- `Configure()` extension to customize `RedisCacheOptions` (connection string, instance name, etc.) +- `CacheDynamicallyCompiledExpressions()` extension to enable expression caching for improved runtime performance + +## Installation + +```shell +dotnet add package RCommon.RedisCache +``` + +## Usage + +```csharp +using RCommon; +using RCommon.RedisCache; + +services.AddRCommon(builder => +{ + builder.WithDistributedCaching(cache => + { + cache.Configure(options => + { + options.Configuration = "localhost:6379"; + options.InstanceName = "MyApp:"; + }); + cache.CacheDynamicallyCompiledExpressions(); + }); +}); +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `RedisCacheService` | `ICacheService` implementation backed by Redis via `IDistributedCache` with JSON serialization | +| `RedisCachingBuilder` | Concrete builder for configuring Redis distributed caching | +| `IRedisCachingBuilder` | Builder interface extending `IDistributedCachingBuilder` | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Caching](https://www.nuget.org/packages/RCommon.Caching) - Core caching abstractions (`ICacheService`, `CacheKey`, builder contracts) +- [RCommon.MemoryCache](https://www.nuget.org/packages/RCommon.MemoryCache) - In-process and distributed memory cache implementations +- [RCommon.Persistence.Caching.RedisCache](https://www.nuget.org/packages/RCommon.Persistence.Caching.RedisCache) - Wires Redis caching into the persistence caching repository decorators + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.RedisCache/RedisCacheService.cs b/Src/RCommon.RedisCache/RedisCacheService.cs index 2e89bf59..b48505ec 100644 --- a/Src/RCommon.RedisCache/RedisCacheService.cs +++ b/Src/RCommon.RedisCache/RedisCacheService.cs @@ -10,47 +10,67 @@ namespace RCommon.RedisCache { /// - /// Just a wrapper for Redis data caching implemented through caching abstractions + /// A wrapper for Redis data caching implemented through the + /// abstraction (backed by StackExchange.Redis). /// - /// This gives us a uniform way for getting/setting cache no matter the caching strategy + /// + /// This gives a uniform way for getting/setting cache no matter the caching strategy. + /// Data is serialized to JSON via before being stored + /// in Redis, and deserialized on retrieval. + /// public class RedisCacheService : ICacheService { private readonly IDistributedCache _distributedCache; private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The underlying distributed cache implementation (Redis). + /// The JSON serializer used to serialize/deserialize cached values. public RedisCacheService(IDistributedCache distributedCache, IJsonSerializer jsonSerializer) { _distributedCache = distributedCache; _jsonSerializer = jsonSerializer; } + /// public TData GetOrCreate(object key, Func data) { - var json = _distributedCache.GetString(key.ToString()); + var cacheKey = key.ToString()!; + var json = _distributedCache.GetString(cacheKey); if (json == null) { - _distributedCache.SetString(key.ToString(), _jsonSerializer.Serialize(data())); - return data(); + // Cache miss: invoke the factory, serialize and store the result, then return it + var result = data(); + _distributedCache.SetString(cacheKey, _jsonSerializer.Serialize(result!)); + return result; } else { - return _jsonSerializer.Deserialize(json); + // Cache hit: deserialize the stored JSON back into the requested type + return _jsonSerializer.Deserialize(json)!; } } + /// public async Task GetOrCreateAsync(object key, Func data) { - var json = await _distributedCache.GetStringAsync(key.ToString()).ConfigureAwait(false); + var cacheKey = key.ToString()!; + var json = await _distributedCache.GetStringAsync(cacheKey).ConfigureAwait(false); if (json == null) { - await _distributedCache.SetStringAsync(key.ToString(), _jsonSerializer.Serialize(data())).ConfigureAwait(false); - return data(); + // Cache miss: invoke the factory, serialize and store the result asynchronously + var result = data(); + await _distributedCache.SetStringAsync(cacheKey, _jsonSerializer.Serialize(result!)).ConfigureAwait(false); + return result; } else { - return _jsonSerializer.Deserialize(json); + // Cache hit: deserialize the stored JSON back into the requested type + return _jsonSerializer.Deserialize(json)!; } } } diff --git a/Src/RCommon.RedisCache/RedisCachingBuilder.cs b/Src/RCommon.RedisCache/RedisCachingBuilder.cs index a0adf641..1820ecc1 100644 --- a/Src/RCommon.RedisCache/RedisCachingBuilder.cs +++ b/Src/RCommon.RedisCache/RedisCachingBuilder.cs @@ -8,19 +8,36 @@ namespace RCommon.RedisCache { + /// + /// Builder for configuring Redis-backed distributed caching using the StackExchange.Redis provider. + /// + /// + /// This is the concrete builder activated by + /// + /// when RedisCachingBuilder is specified as the type parameter. + /// public class RedisCachingBuilder : IRedisCachingBuilder { + /// + /// Initializes a new instance of the class. + /// + /// The RCommon builder whose is used for service registration. public RedisCachingBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers any default services required by the Redis cache builder. + /// + /// The service collection to register services into. protected void RegisterServices(IServiceCollection services) { - + } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Security/Authorization/AuthorizationException.cs b/Src/RCommon.Security/Authorization/AuthorizationException.cs index 89e27d08..f729c1bb 100644 --- a/Src/RCommon.Security/Authorization/AuthorizationException.cs +++ b/Src/RCommon.Security/Authorization/AuthorizationException.cs @@ -23,7 +23,7 @@ public class AuthorizationException : ApplicationException /// /// Error code. /// - public string Code { get; } + public string? Code { get; } /// /// Creates a new object. @@ -60,13 +60,19 @@ public AuthorizationException(string message, Exception innerException) /// Exception message /// Exception code /// Inner exception - public AuthorizationException(string message = null, string code = null, Exception innerException = null) + public AuthorizationException(string? message = null, string? code = null, Exception? innerException = null) : base(message, innerException) { Code = code; LogLevel = LogLevel.Warning; } + /// + /// Adds a key/value pair to the exception's dictionary and returns the current instance for fluent chaining. + /// + /// The key to store in the data dictionary. + /// The value associated with the key. + /// The current instance. public AuthorizationException WithData(string name, object value) { Data[name] = value; diff --git a/Src/RCommon.Security/Claims/ClaimTypesConst.cs b/Src/RCommon.Security/Claims/ClaimTypesConst.cs index ae338f80..95a7c83b 100644 --- a/Src/RCommon.Security/Claims/ClaimTypesConst.cs +++ b/Src/RCommon.Security/Claims/ClaimTypesConst.cs @@ -7,6 +7,10 @@ namespace RCommon.Security.Claims { + /// + /// Provides configurable constants for standard claim type URIs used throughout the security subsystem. + /// Each property defaults to the corresponding value and can be overridden at startup. + /// public static class ClaimTypesConst { /// diff --git a/Src/RCommon.Security/Claims/CurrentPrincipalAccessorBase.cs b/Src/RCommon.Security/Claims/CurrentPrincipalAccessorBase.cs index d581c5bb..a75e3781 100644 --- a/Src/RCommon.Security/Claims/CurrentPrincipalAccessorBase.cs +++ b/Src/RCommon.Security/Claims/CurrentPrincipalAccessorBase.cs @@ -8,21 +8,45 @@ namespace RCommon.Security.Claims { + /// + /// Abstract base class for accessing and temporarily replacing the current . + /// Uses to maintain an override principal that flows across async contexts. + /// + /// + /// Derived classes must implement to provide the default principal + /// when no override has been set (e.g., from or an HTTP context). + /// public abstract class CurrentPrincipalAccessorBase : ICurrentPrincipalAccessor { - public ClaimsPrincipal Principal => _currentPrincipal.Value ?? GetClaimsPrincipal(); + /// + public ClaimsPrincipal? Principal => _currentPrincipal.Value ?? GetClaimsPrincipal(); - private readonly AsyncLocal _currentPrincipal = new AsyncLocal(); + /// + /// Async-local storage that holds the overridden principal for the current execution context. + /// + private readonly AsyncLocal _currentPrincipal = new AsyncLocal(); - protected abstract ClaimsPrincipal GetClaimsPrincipal(); + /// + /// When implemented in a derived class, returns the default for the current context. + /// + /// The current , or null if none is available. + protected abstract ClaimsPrincipal? GetClaimsPrincipal(); + /// public virtual IDisposable Change(ClaimsPrincipal principal) { return SetCurrent(principal); } + /// + /// Replaces the current principal with and returns an + /// that restores the previous principal when disposed. + /// + /// The new principal to set. + /// An that restores the previous principal on disposal. private IDisposable SetCurrent(ClaimsPrincipal principal) { + // Capture the current principal so it can be restored when the scope ends. var parent = Principal; _currentPrincipal.Value = principal; return new DisposeAction(() => diff --git a/Src/RCommon.Security/Claims/CurrentPrincipalAccessorExtensions.cs b/Src/RCommon.Security/Claims/CurrentPrincipalAccessorExtensions.cs index b3d7e9d2..4ec859ab 100644 --- a/Src/RCommon.Security/Claims/CurrentPrincipalAccessorExtensions.cs +++ b/Src/RCommon.Security/Claims/CurrentPrincipalAccessorExtensions.cs @@ -7,18 +7,40 @@ namespace RCommon.Security.Claims { + /// + /// Convenience extension methods for that allow changing the + /// current principal from a single , a collection of claims, or a . + /// public static class CurrentPrincipalAccessorExtensions { + /// + /// Temporarily replaces the current principal with one containing the specified . + /// + /// The principal accessor to change. + /// The claim to include in the new principal. + /// An that restores the previous principal on disposal. public static IDisposable Change(this ICurrentPrincipalAccessor currentPrincipalAccessor, Claim claim) { return currentPrincipalAccessor.Change(new[] { claim }); } + /// + /// Temporarily replaces the current principal with one built from the specified . + /// + /// The principal accessor to change. + /// The claims to include in the new principal's identity. + /// An that restores the previous principal on disposal. public static IDisposable Change(this ICurrentPrincipalAccessor currentPrincipalAccessor, IEnumerable claims) { return currentPrincipalAccessor.Change(new ClaimsIdentity(claims)); } + /// + /// Temporarily replaces the current principal with one wrapping the specified . + /// + /// The principal accessor to change. + /// The identity to wrap in a new . + /// An that restores the previous principal on disposal. public static IDisposable Change(this ICurrentPrincipalAccessor currentPrincipalAccessor, ClaimsIdentity claimsIdentity) { return currentPrincipalAccessor.Change(new ClaimsPrincipal(claimsIdentity)); diff --git a/Src/RCommon.Security/Claims/ICurrentPrincipalAccessor.cs b/Src/RCommon.Security/Claims/ICurrentPrincipalAccessor.cs index eba0356d..4fbb7f22 100644 --- a/Src/RCommon.Security/Claims/ICurrentPrincipalAccessor.cs +++ b/Src/RCommon.Security/Claims/ICurrentPrincipalAccessor.cs @@ -7,10 +7,21 @@ namespace RCommon.Security.Claims { + /// + /// Provides access to the current and allows temporarily replacing it within a scoped context. + /// public interface ICurrentPrincipalAccessor { - ClaimsPrincipal Principal { get; } + /// + /// Gets the current , or null if none is available. + /// + ClaimsPrincipal? Principal { get; } + /// + /// Temporarily replaces the current principal with the specified . + /// + /// The new principal to set as current. + /// An that restores the previous principal when disposed. IDisposable Change(ClaimsPrincipal principal); } } diff --git a/Src/RCommon.Security/Claims/ThreadCurrentPrincipalAccessor.cs b/Src/RCommon.Security/Claims/ThreadCurrentPrincipalAccessor.cs index 20a9756a..4168d429 100644 --- a/Src/RCommon.Security/Claims/ThreadCurrentPrincipalAccessor.cs +++ b/Src/RCommon.Security/Claims/ThreadCurrentPrincipalAccessor.cs @@ -8,9 +8,18 @@ namespace RCommon.Security.Claims { + /// + /// An implementation that retrieves the default principal + /// from . + /// + /// + /// This is the default accessor registered by . + /// In ASP.NET Core scenarios, consider using an HTTP-context-based accessor instead. + /// public class ThreadCurrentPrincipalAccessor : CurrentPrincipalAccessorBase { - protected override ClaimsPrincipal GetClaimsPrincipal() + /// + protected override ClaimsPrincipal? GetClaimsPrincipal() { return Thread.CurrentPrincipal as ClaimsPrincipal; } diff --git a/Src/RCommon.Security/ClaimsIdentityExtensions.cs b/Src/RCommon.Security/ClaimsIdentityExtensions.cs index 6cc5ba7a..25164990 100644 --- a/Src/RCommon.Security/ClaimsIdentityExtensions.cs +++ b/Src/RCommon.Security/ClaimsIdentityExtensions.cs @@ -9,8 +9,17 @@ namespace RCommon.Security { + /// + /// Extension methods for , , and + /// that simplify extracting well-known claim values and managing claims collections. + /// public static class ClaimsIdentityExtensions { + /// + /// Extracts the user identifier from the principal's claims as a . + /// + /// The claims principal to search. + /// The parsed user ID, or null if the claim is missing or not a valid GUID. public static Guid? FindUserId(this ClaimsPrincipal principal) { Guard.IsNotNull(principal, nameof(principal)); @@ -29,6 +38,11 @@ public static class ClaimsIdentityExtensions return null; } + /// + /// Extracts the user identifier from the identity's claims as a . + /// + /// The identity to search. Must be castable to . + /// The parsed user ID, or null if the claim is missing or not a valid GUID. public static Guid? FindUserId(this IIdentity identity) { Guard.IsNotNull(identity, nameof(identity)); @@ -49,6 +63,11 @@ public static class ClaimsIdentityExtensions return null; } + /// + /// Extracts the tenant identifier from the principal's claims as a . + /// + /// The claims principal to search. + /// The parsed tenant ID, or null if the claim is missing or not a valid GUID. public static Guid? FindTenantId(this ClaimsPrincipal principal) { Guard.IsNotNull(principal, nameof(principal)); @@ -67,6 +86,11 @@ public static class ClaimsIdentityExtensions return null; } + /// + /// Extracts the tenant identifier from the identity's claims as a . + /// + /// The identity to search. Must be castable to . + /// The parsed tenant ID, or null if the claim is missing or not a valid GUID. public static Guid? FindTenantId(this IIdentity identity) { Guard.IsNotNull(identity, nameof(identity)); @@ -87,7 +111,12 @@ public static class ClaimsIdentityExtensions return null; } - public static string FindClientId(this ClaimsPrincipal principal) + /// + /// Extracts the client identifier from the principal's claims. + /// + /// The claims principal to search. + /// The client ID string, or null if the claim is missing or empty. + public static string? FindClientId(this ClaimsPrincipal principal) { Guard.IsNotNull(principal, nameof(principal)); @@ -100,7 +129,12 @@ public static string FindClientId(this ClaimsPrincipal principal) return clientIdOrNull.Value; } - public static string FindClientId(this IIdentity identity) + /// + /// Extracts the client identifier from the identity's claims. + /// + /// The identity to search. Must be castable to . + /// The client ID string, or null if the claim is missing or empty. + public static string? FindClientId(this IIdentity identity) { Guard.IsNotNull(identity, nameof(identity)); @@ -115,8 +149,14 @@ public static string FindClientId(this IIdentity identity) return clientIdOrNull.Value; } - + + /// + /// Adds a claim to the identity only if no claim with the same type already exists (case-insensitive comparison). + /// + /// The identity to add the claim to. + /// The claim to add. + /// The same instance for fluent chaining. public static ClaimsIdentity AddIfNotContains(this ClaimsIdentity claimsIdentity, Claim claim) { Guard.IsNotNull(claimsIdentity, nameof(claimsIdentity)); @@ -129,10 +169,17 @@ public static ClaimsIdentity AddIfNotContains(this ClaimsIdentity claimsIdentity return claimsIdentity; } + /// + /// Removes all existing claims of the same type and adds the new . + /// + /// The identity to modify. + /// The claim to set, replacing any existing claims of the same type. + /// The same instance for fluent chaining. public static ClaimsIdentity AddOrReplace(this ClaimsIdentity claimsIdentity, Claim claim) { Guard.IsNotNull(claimsIdentity, nameof(claimsIdentity)); + // Remove all claims matching the type before adding the replacement. foreach (var x in claimsIdentity.FindAll(claim.Type).ToList()) { claimsIdentity.RemoveClaim(x); @@ -143,6 +190,13 @@ public static ClaimsIdentity AddOrReplace(this ClaimsIdentity claimsIdentity, Cl return claimsIdentity; } + /// + /// Adds a to the principal only if no identity with the same + /// already exists (case-insensitive comparison). + /// + /// The principal to add the identity to. + /// The identity to add. + /// The same instance for fluent chaining. public static ClaimsPrincipal AddIdentityIfNotContains(this ClaimsPrincipal principal, ClaimsIdentity identity) { Guard.IsNotNull(principal, nameof(principal)); diff --git a/Src/RCommon.Security/Clients/CurrentClient.cs b/Src/RCommon.Security/Clients/CurrentClient.cs index 457a6e3e..c10a6844 100644 --- a/Src/RCommon.Security/Clients/CurrentClient.cs +++ b/Src/RCommon.Security/Clients/CurrentClient.cs @@ -7,14 +7,24 @@ namespace RCommon.Security.Clients { + /// + /// Default implementation of that resolves the client identity + /// from the current via . + /// public class CurrentClient : ICurrentClient { - public virtual string Id => _principalAccessor.Principal?.FindClientId(); + /// + public virtual string? Id => _principalAccessor.Principal?.FindClientId(); + /// public virtual bool IsAuthenticated => Id != null; private readonly ICurrentPrincipalAccessor _principalAccessor; + /// + /// Initializes a new instance of the class. + /// + /// The accessor used to retrieve the current claims principal. public CurrentClient(ICurrentPrincipalAccessor principalAccessor) { _principalAccessor = principalAccessor; diff --git a/Src/RCommon.Security/Clients/ICurrentClient.cs b/Src/RCommon.Security/Clients/ICurrentClient.cs index 5d842fa6..ed88b297 100644 --- a/Src/RCommon.Security/Clients/ICurrentClient.cs +++ b/Src/RCommon.Security/Clients/ICurrentClient.cs @@ -1,8 +1,19 @@ namespace RCommon.Security.Clients { + /// + /// Represents the currently authenticated client (e.g., an OAuth client application) in a multi-client environment. + /// public interface ICurrentClient { - string Id { get; } + /// + /// Gets the unique identifier of the current client, derived from the claim. + /// Returns null if no client identity is present. + /// + string? Id { get; } + + /// + /// Gets a value indicating whether the current request has an authenticated client identity. + /// bool IsAuthenticated { get; } } } \ No newline at end of file diff --git a/Src/RCommon.Security/RCommon.Security.csproj b/Src/RCommon.Security/RCommon.Security.csproj index 35579496..5e21e874 100644 --- a/Src/RCommon.Security/RCommon.Security.csproj +++ b/Src/RCommon.Security/RCommon.Security.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Security https://rcommon.com diff --git a/Src/RCommon.Security/README.md b/Src/RCommon.Security/README.md index d7d3bd48..d9037b1b 100644 --- a/Src/RCommon.Security/README.md +++ b/Src/RCommon.Security/README.md @@ -1,3 +1,103 @@ # RCommon.Security -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Provides claims-based security abstractions for RCommon, including current user and current client identity access built on top of `ClaimsPrincipal`, with configurable claim type mappings and multi-tenancy support. + +## Features + +- **Current user abstraction** -- `ICurrentUser` exposes the authenticated user's ID, tenant ID, roles, and claims without depending on a specific auth framework +- **Current client abstraction** -- `ICurrentClient` identifies the calling OAuth/API client from the `client_id` claim +- **ClaimsPrincipal accessor** -- `ICurrentPrincipalAccessor` provides the current principal with support for temporary principal replacement via scoped `IDisposable` +- **AsyncLocal principal override** -- `CurrentPrincipalAccessorBase` uses `AsyncLocal` so overridden principals flow across async contexts +- **Configurable claim types** -- `ClaimTypesConst` allows customizing which claim URIs map to user ID, tenant ID, client ID, roles, etc. +- **ClaimsIdentity extensions** -- helper methods for finding user/tenant/client IDs and for safely adding or replacing claims +- **Authorization exception** -- `AuthorizationException` with configurable severity, error codes, and fluent data attachment +- **Fluent builder API** -- integrates with the `AddRCommon()` builder pattern for one-line DI registration + +## Installation + +```shell +dotnet add package RCommon.Security +``` + +## Usage + +```csharp +using RCommon; +using RCommon.Security.Users; +using RCommon.Security.Clients; + +// Register security services in your DI setup +services.AddRCommon(config => +{ + config.WithClaimsAndPrincipalAccessor(); +}); + +// Inject ICurrentUser or ICurrentClient in your services +public class TenantService +{ + private readonly ICurrentUser _currentUser; + private readonly ICurrentClient _currentClient; + + public TenantService(ICurrentUser currentUser, ICurrentClient currentClient) + { + _currentUser = currentUser; + _currentClient = currentClient; + } + + public Guid GetTenantId() + { + if (!_currentUser.IsAuthenticated) + throw new UnauthorizedAccessException("User is not authenticated."); + + return _currentUser.TenantId + ?? throw new InvalidOperationException("No tenant claim found."); + } + + public string GetClientId() + { + return _currentClient.Id + ?? throw new InvalidOperationException("No client identity found."); + } + + public bool IsInRole(string role) + { + return _currentUser.Roles.Contains(role); + } +} +``` + +### Customizing Claim Types + +```csharp +// Override the default claim type URIs at startup if your identity provider uses custom claims +ClaimTypesConst.UserId = "sub"; +ClaimTypesConst.TenantId = "tenant"; +ClaimTypesConst.ClientId = "azp"; +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `ICurrentUser` | Provides the authenticated user's ID, tenant ID, roles, and claim lookups | +| `CurrentUser` | Default implementation that reads from the current `ClaimsPrincipal` | +| `ICurrentClient` | Provides the authenticated client application's ID and authentication status | +| `CurrentClient` | Default implementation that reads the `client_id` claim from the principal | +| `ICurrentPrincipalAccessor` | Accesses the current `ClaimsPrincipal` and supports scoped replacement | +| `ThreadCurrentPrincipalAccessor` | Default accessor that reads from `Thread.CurrentPrincipal` | +| `CurrentPrincipalAccessorBase` | Abstract base using `AsyncLocal` for async-safe principal overrides | +| `ClaimTypesConst` | Configurable constants for standard claim type URIs (user ID, role, tenant, etc.) | +| `AuthorizationException` | Exception for unauthorized requests with log level, error code, and fluent data API | +| `ClaimsIdentityExtensions` | Extension methods for extracting user/tenant/client IDs and managing claims | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Security/SecurityConfigurationExtensions.cs b/Src/RCommon.Security/SecurityConfigurationExtensions.cs index ed251079..d047a2fc 100644 --- a/Src/RCommon.Security/SecurityConfigurationExtensions.cs +++ b/Src/RCommon.Security/SecurityConfigurationExtensions.cs @@ -10,9 +10,18 @@ namespace RCommon { + /// + /// Extension methods for that register security-related services + /// such as claims-based principal access, current user, and current client abstractions. + /// public static class SecurityConfigurationExtensions { - + /// + /// Registers the default claims and principal accessor services into the dependency injection container. + /// This includes , , and . + /// + /// The RCommon builder to configure. + /// The same instance for fluent chaining. public static IRCommonBuilder WithClaimsAndPrincipalAccessor(this IRCommonBuilder config) { config.Services.AddTransient(); diff --git a/Src/RCommon.Security/Users/CurrentUser.cs b/Src/RCommon.Security/Users/CurrentUser.cs index 8822ea21..cf4f2971 100644 --- a/Src/RCommon.Security/Users/CurrentUser.cs +++ b/Src/RCommon.Security/Users/CurrentUser.cs @@ -8,37 +8,53 @@ namespace RCommon.Security.Users { + /// + /// Default implementation of that resolves user information + /// from the current via . + /// public class CurrentUser : ICurrentUser { + /// + /// Shared empty array returned when the principal has no claims, avoiding repeated allocations. + /// private static readonly Claim[] EmptyClaimsArray = new Claim[0]; + private readonly ICurrentPrincipalAccessor _principalAccessor; + /// + /// Initializes a new instance of the class. + /// + /// The accessor used to retrieve the current claims principal. public CurrentUser(ICurrentPrincipalAccessor principalAccessor) { _principalAccessor = principalAccessor; } - - - + /// public virtual bool IsAuthenticated => Id.HasValue; + /// public virtual Guid? Id => _principalAccessor.Principal?.FindUserId(); + /// public virtual Guid? TenantId => _principalAccessor.Principal?.FindTenantId(); + /// public virtual string[] Roles => FindClaims(ClaimTypesConst.Role).Select(c => c.Value).Distinct().ToArray(); - public virtual Claim FindClaim(string claimType) + /// + public virtual Claim? FindClaim(string claimType) { return _principalAccessor.Principal?.Claims.FirstOrDefault(c => c.Type == claimType); } + /// public virtual Claim[] FindClaims(string claimType) { return _principalAccessor.Principal?.Claims.Where(c => c.Type == claimType).ToArray() ?? EmptyClaimsArray; } + /// public virtual Claim[] GetAllClaims() { return _principalAccessor.Principal?.Claims.ToArray() ?? EmptyClaimsArray; diff --git a/Src/RCommon.Security/Users/CurrentUserExtensions.cs b/Src/RCommon.Security/Users/CurrentUserExtensions.cs index 10df70d5..2a6cc9b5 100644 --- a/Src/RCommon.Security/Users/CurrentUserExtensions.cs +++ b/Src/RCommon.Security/Users/CurrentUserExtensions.cs @@ -9,13 +9,31 @@ namespace RCommon.Security.Users { + /// + /// Convenience extension methods for that simplify claim value retrieval + /// and provide strongly-typed access to user identity properties. + /// public static class CurrentUserExtensions { - public static string FindClaimValue(this ICurrentUser currentUser, string claimType) + /// + /// Finds a claim of the specified type and returns its string value. + /// + /// The current user instance. + /// The claim type URI to search for. + /// The claim value as a string, or null if the claim is not found. + public static string? FindClaimValue(this ICurrentUser currentUser, string claimType) { return currentUser.FindClaim(claimType)?.Value; } + /// + /// Finds a claim of the specified type and converts its value to . + /// + /// The target value type to convert the claim value to. + /// The current user instance. + /// The claim type URI to search for. + /// The converted claim value, or default if the claim is not found. + /// Uses with . public static T FindClaimValue(this ICurrentUser currentUser, string claimType) where T : struct { @@ -27,6 +45,12 @@ public static T FindClaimValue(this ICurrentUser currentUser, string claimTyp return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); } + /// + /// Gets the current user's ID, asserting that it is not null. + /// + /// The current user instance. + /// The user's identifier. + /// Thrown if is null. public static Guid GetId(this ICurrentUser currentUser) { Debug.Assert(currentUser.Id != null, "currentUser.Id != null"); diff --git a/Src/RCommon.Security/Users/ICurrentUser.cs b/Src/RCommon.Security/Users/ICurrentUser.cs index 2b1fb9d8..f5e464e8 100644 --- a/Src/RCommon.Security/Users/ICurrentUser.cs +++ b/Src/RCommon.Security/Users/ICurrentUser.cs @@ -3,15 +3,49 @@ namespace RCommon.Security.Users { + /// + /// Represents the currently authenticated user, providing access to identity properties and claims. + /// public interface ICurrentUser { + /// + /// Gets the unique identifier of the current user, or null if no user is authenticated. + /// Guid? Id { get; } + + /// + /// Gets a value indicating whether the current user is authenticated (i.e., is not null). + /// bool IsAuthenticated { get; } + + /// + /// Gets the distinct set of role names assigned to the current user. + /// string[] Roles { get; } + + /// + /// Gets the tenant identifier of the current user, or null if no tenant claim is present. + /// Guid? TenantId { get; } - Claim FindClaim(string claimType); + /// + /// Finds the first claim matching the specified . + /// + /// The claim type URI to search for. + /// The matching , or null if not found. + Claim? FindClaim(string claimType); + + /// + /// Finds all claims matching the specified . + /// + /// The claim type URI to search for. + /// An array of matching claims, or an empty array if none are found. Claim[] FindClaims(string claimType); + + /// + /// Gets all claims associated with the current user. + /// + /// An array of all claims, or an empty array if the user has no claims. Claim[] GetAllClaims(); } } diff --git a/Src/RCommon.SendGrid/README.md b/Src/RCommon.SendGrid/README.md index a0bdadc4..dde98a38 100644 --- a/Src/RCommon.SendGrid/README.md +++ b/Src/RCommon.SendGrid/README.md @@ -1,3 +1,84 @@ # RCommon.SendGrid -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more \ No newline at end of file +A SendGrid implementation of the RCommon `IEmailService` abstraction, enabling email delivery through the SendGrid API using standard `System.Net.Mail.MailMessage` objects. + +## Features + +- Implements `IEmailService` using the SendGrid API client +- Accepts standard `MailMessage` objects and converts them to SendGrid messages automatically +- Supports HTML and plain text email bodies +- Streams file attachments to the SendGrid API +- Supports sending to multiple recipients in a single call +- `EmailSent` event for post-send notifications +- API key configuration via the options pattern +- Fluent DI registration through the `AddRCommon()` builder + +## Installation + +```shell +dotnet add package RCommon.SendGrid +``` + +## Usage + +```csharp +using RCommon; + +services.AddRCommon() + .WithSendGridEmailServices(settings => + { + settings.SendGridApiKey = "your-sendgrid-api-key"; + settings.FromEmailDefault = "noreply@example.com"; + settings.FromNameDefault = "My Application"; + }); +``` + +Then inject and use `IEmailService`: + +```csharp +using RCommon.Emailing; +using System.Net.Mail; + +public class NotificationService +{ + private readonly IEmailService _emailService; + + public NotificationService(IEmailService emailService) + { + _emailService = emailService; + } + + public async Task SendWelcomeEmailAsync(string toAddress) + { + var message = new MailMessage("noreply@example.com", toAddress) + { + Subject = "Welcome!", + Body = "

Welcome to our app

", + IsBodyHtml = true + }; + + await _emailService.SendEmailAsync(message); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `SendGridEmailService` | `IEmailService` implementation that sends email via the SendGrid API | +| `SendGridEmailSettings` | Configuration for the SendGrid API key and default sender details | +| `SendGridEmailingConfigurationExtensions` | Provides `WithSendGridEmailServices()` for the RCommon builder | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Emailing](https://www.nuget.org/packages/RCommon.Emailing) - Core email abstraction with SMTP implementation +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.SendGrid/SendGridEmailService.cs b/Src/RCommon.SendGrid/SendGridEmailService.cs index a67c0c83..0847445b 100644 --- a/Src/RCommon.SendGrid/SendGridEmailService.cs +++ b/Src/RCommon.SendGrid/SendGridEmailService.cs @@ -12,23 +12,43 @@ namespace RCommon.Emailing.SendGrid { + /// + /// Implementation of that sends email through the SendGrid API. + /// + /// + /// Converts standard objects to SendGrid messages. The API key is + /// configured via . + /// public class SendGridEmailService : IEmailService { + /// + public event EventHandler? EmailSent; - public event EventHandler EmailSent; - + /// + /// + /// Synchronous wrapper that delegates to using . + /// public void SendEmail(MailMessage message) { AsyncHelper.RunSync(() => SendEmailAsync(message)); } private readonly SendGridClient _client; - - public SendGridEmailService(IOptions settings, ILogger logger) - { - _client = new SendGridClient(settings.Value.SendGridApiKey); + + /// + /// Initializes a new instance of the class. + /// + /// The SendGrid configuration options containing the API key. + /// The logger instance for diagnostic output. + public SendGridEmailService(IOptions settings, ILogger logger) + { + _client = new SendGridClient(settings.Value.SendGridApiKey); } + /// + /// Raises the event if any subscribers are attached. + /// + /// The mail message that was sent. private void OnEmailSent(MailMessage message) { if (EmailSent != null) @@ -37,20 +57,28 @@ private void OnEmailSent(MailMessage message) } } + /// + /// + /// Converts the to a SendGrid , + /// streams any attachments, then sends via the SendGrid API client. + /// public async Task SendEmailAsync(MailMessage message) { + // No-op when there are no recipients. if (message.To.Count == 0) { await Task.CompletedTask; } - var sgMessage = MailHelper.CreateSingleEmailToMultipleRecipients(from: new EmailAddress(message.From.Address, message.From.DisplayName), + // Map the MailMessage to a SendGrid message, choosing plain text or HTML based on IsBodyHtml. + var sgMessage = MailHelper.CreateSingleEmailToMultipleRecipients(from: new EmailAddress(message.From!.Address, message.From.DisplayName), tos: message.To.Select(r => new EmailAddress(r.Address, r.DisplayName)).ToList(), subject: message.Subject, plainTextContent: message.IsBodyHtml ? null : message.Body, htmlContent: message.IsBodyHtml ? message.Body : null); + // Stream each attachment into the SendGrid message. if (message.Attachments != null) { foreach (var attachment in message.Attachments) diff --git a/Src/RCommon.SendGrid/SendGridEmailSettings.cs b/Src/RCommon.SendGrid/SendGridEmailSettings.cs index 373da35f..01fb6a7f 100644 --- a/Src/RCommon.SendGrid/SendGridEmailSettings.cs +++ b/Src/RCommon.SendGrid/SendGridEmailSettings.cs @@ -6,16 +6,33 @@ namespace RCommon.Emailing.SendGrid { + /// + /// Configuration settings for the . + /// Typically bound from an application configuration section (e.g., appsettings.json). + /// public class SendGridEmailSettings { - + /// + /// Initializes a new instance of the class. + /// public SendGridEmailSettings() { } + /// + /// Gets or sets the SendGrid API key used to authenticate with the SendGrid service. + /// public string? SendGridApiKey { get; set; } + + /// + /// Gets or sets the default sender email address used when no explicit "From" address is specified. + /// public string? FromEmailDefault { get; set; } + + /// + /// Gets or sets the default sender display name used when no explicit "From" name is specified. + /// public string? FromNameDefault { get; set; } } } diff --git a/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs b/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs index 0f787612..73431c90 100644 --- a/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs +++ b/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs @@ -10,9 +10,18 @@ namespace RCommon { + /// + /// Extension methods for that register SendGrid-based email services. + /// public static class SendGridEmailingConfigurationExtensions { - + /// + /// Registers as the implementation + /// and configures the SendGrid settings via the provided delegate. + /// + /// The RCommon builder to configure. + /// A delegate to configure . + /// The same instance for fluent chaining. public static IRCommonBuilder WithSendGridEmailServices(this IRCommonBuilder config, Action emailSettings) { config.Services.Configure(emailSettings); diff --git a/Src/RCommon.SystemTextJson/ITextJsonBuilder.cs b/Src/RCommon.SystemTextJson/ITextJsonBuilder.cs index 539c7b15..1c3f2911 100644 --- a/Src/RCommon.SystemTextJson/ITextJsonBuilder.cs +++ b/Src/RCommon.SystemTextJson/ITextJsonBuilder.cs @@ -7,6 +7,11 @@ namespace RCommon.SystemTextJson { + /// + /// Builder interface for configuring JSON serialization using the System.Text.Json library. + /// + /// + /// public interface ITextJsonBuilder : IJsonBuilder { } diff --git a/Src/RCommon.SystemTextJson/ITextJsonBuilderExtensions.cs b/Src/RCommon.SystemTextJson/ITextJsonBuilderExtensions.cs index 0e8f5cc8..f0162fc1 100644 --- a/Src/RCommon.SystemTextJson/ITextJsonBuilderExtensions.cs +++ b/Src/RCommon.SystemTextJson/ITextJsonBuilderExtensions.cs @@ -10,8 +10,17 @@ namespace RCommon.SystemTextJson { + /// + /// Provides extension methods for to configure System.Text.Json settings. + /// public static class ITextJsonBuilderExtensions { + /// + /// Configures the underlying used by the System.Text.Json serializer. + /// + /// The System.Text.Json builder instance. + /// An action to configure . + /// The for further chaining. public static ITextJsonBuilder Configure(this ITextJsonBuilder builder, Action options) { builder.Services.Configure(options); diff --git a/Src/RCommon.SystemTextJson/JsonByteEnumConverter.cs b/Src/RCommon.SystemTextJson/JsonByteEnumConverter.cs index af29da0f..cf3cdfcd 100644 --- a/Src/RCommon.SystemTextJson/JsonByteEnumConverter.cs +++ b/Src/RCommon.SystemTextJson/JsonByteEnumConverter.cs @@ -8,17 +8,42 @@ namespace RCommon.SystemTextJson { + /// + /// A custom that serializes enum values as their underlying + /// numeric representation and deserializes from string names. + /// + /// The enum type to convert. + /// + /// During reading, the converter expects the JSON token to be a string containing the enum member name. + /// During writing, the converter outputs the numeric value of the enum member. + /// + /// public class JsonByteEnumConverter : JsonConverter where T : Enum { + /// + /// Reads a JSON string token and parses it into the corresponding enum value. + /// + /// The UTF-8 JSON reader. + /// The target enum type. + /// The serializer options. + /// The parsed enum value of type . public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - T value = (T)(Enum.Parse(typeof(T), reader.GetString())); + // Parse the string token as the enum member name + T value = (T)(Enum.Parse(typeof(T), reader.GetString()!)); return value; } + /// + /// Writes the enum value as its numeric representation. + /// + /// The UTF-8 JSON writer. + /// The enum value to serialize. + /// The serializer options. public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { + // Re-parse the enum to its base Enum type and convert to byte for numeric output Enum test = (Enum)Enum.Parse(typeof(T), value.ToString()); writer.WriteNumberValue(Convert.ToByte(test)); } diff --git a/Src/RCommon.SystemTextJson/JsonIntEnumConverter.cs b/Src/RCommon.SystemTextJson/JsonIntEnumConverter.cs index 0983829a..bcf51017 100644 --- a/Src/RCommon.SystemTextJson/JsonIntEnumConverter.cs +++ b/Src/RCommon.SystemTextJson/JsonIntEnumConverter.cs @@ -8,17 +8,42 @@ namespace RCommon.SystemTextJson { + /// + /// A custom that serializes enum values as their underlying + /// numeric representation and deserializes from string names. + /// + /// The enum type to convert. + /// + /// During reading, the converter expects the JSON token to be a string containing the enum member name. + /// During writing, the converter outputs the numeric value of the enum member. + /// + /// public class JsonIntEnumConverter : JsonConverter where T : Enum { + /// + /// Reads a JSON string token and parses it into the corresponding enum value. + /// + /// The UTF-8 JSON reader. + /// The target enum type. + /// The serializer options. + /// The parsed enum value of type . public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - T value = (T)(Enum.Parse(typeof(T), reader.GetString())); + // Parse the string token as the enum member name + T value = (T)(Enum.Parse(typeof(T), reader.GetString()!)); return value; } + /// + /// Writes the enum value as its numeric representation. + /// + /// The UTF-8 JSON writer. + /// The enum value to serialize. + /// The serializer options. public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { + // Re-parse the enum to its base Enum type and convert to int for numeric output Enum test = (Enum)Enum.Parse(typeof(T), value.ToString()); writer.WriteNumberValue(Convert.ToInt32(test)); } diff --git a/Src/RCommon.SystemTextJson/RCommon.SystemTextJson.csproj b/Src/RCommon.SystemTextJson/RCommon.SystemTextJson.csproj index 4bcb3828..20567333 100644 --- a/Src/RCommon.SystemTextJson/RCommon.SystemTextJson.csproj +++ b/Src/RCommon.SystemTextJson/RCommon.SystemTextJson.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.SystemTextJson https://rcommon.com @@ -16,18 +17,6 @@ README.md - - - - - - - - - - - - True diff --git a/Src/RCommon.SystemTextJson/README.md b/Src/RCommon.SystemTextJson/README.md index 2a2ac4aa..c11b706e 100644 --- a/Src/RCommon.SystemTextJson/README.md +++ b/Src/RCommon.SystemTextJson/README.md @@ -1,3 +1,83 @@ - # RCommon.SystemTextJson +# RCommon.SystemTextJson -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +System.Text.Json implementation of RCommon's `IJsonSerializer` abstraction. This package registers `TextJsonSerializer` into the dependency injection container and provides fluent configuration of `JsonSerializerOptions`, along with custom enum converters. + +## Features + +- Implements `IJsonSerializer` using the built-in System.Text.Json library +- Per-call options for camelCase property naming and indented formatting +- Fluent configuration of `JsonSerializerOptions` through the builder pattern +- Custom `JsonByteEnumConverter` for serializing enums as byte values +- Custom `JsonIntEnumConverter` for serializing enums as int values +- Integrates with RCommon's `AddRCommon()` / `WithJsonSerialization()` pipeline +- Registered as a transient service in the DI container + +## Installation + +```shell +dotnet add package RCommon.SystemTextJson +``` + +## Usage + +Register the System.Text.Json serializer through the RCommon builder: + +```csharp +using RCommon; +using RCommon.SystemTextJson; + +services.AddRCommon(builder => +{ + builder.WithJsonSerialization(serializer => + { + serializer.Configure(options => + { + options.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; + options.Converters.Add(new JsonIntEnumConverter()); + }); + }); +}); +``` + +Then inject and use `IJsonSerializer` in your services: + +```csharp +public class OrderService +{ + private readonly IJsonSerializer _serializer; + + public OrderService(IJsonSerializer serializer) + { + _serializer = serializer; + } + + public string SerializeOrder(Order order) + { + return _serializer.Serialize(order); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `TextJsonSerializer` | `IJsonSerializer` implementation backed by System.Text.Json | +| `TextJsonBuilder` | Registers `TextJsonSerializer` into the DI container | +| `ITextJsonBuilder` | Builder interface for System.Text.Json-specific configuration | +| `ITextJsonBuilderExtensions` | Provides `Configure(Action)` for customizing serializer options | +| `JsonIntEnumConverter` | Custom converter that serializes enums as their int numeric value | +| `JsonByteEnumConverter` | Custom converter that serializes enums as their byte numeric value | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Json](https://www.nuget.org/packages/RCommon.Json) - JSON serialization abstractions (IJsonSerializer, options) +- [RCommon.JsonNet](https://www.nuget.org/packages/RCommon.JsonNet) - Alternative implementation using Newtonsoft.Json + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.SystemTextJson/TextJsonBuilder.cs b/Src/RCommon.SystemTextJson/TextJsonBuilder.cs index 8774482c..747a1292 100644 --- a/Src/RCommon.SystemTextJson/TextJsonBuilder.cs +++ b/Src/RCommon.SystemTextJson/TextJsonBuilder.cs @@ -8,19 +8,35 @@ namespace RCommon.SystemTextJson { + /// + /// Default implementation of that registers + /// the System.Text.Json-based into the DI container. + /// + /// + /// public class TextJsonBuilder : ITextJsonBuilder { + /// + /// Initializes a new instance of and registers JSON serialization services. + /// + /// The RCommon builder providing access to the . public TextJsonBuilder(IRCommonBuilder builder) { Services = builder.Services; this.RegisterServices(Services); } + /// + /// Registers the as the implementation + /// with a transient lifetime. + /// + /// The service collection to register into. protected void RegisterServices(IServiceCollection services) { services.AddTransient(); } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.SystemTextJson/TextJsonSerializer.cs b/Src/RCommon.SystemTextJson/TextJsonSerializer.cs index 75fd91c9..6384efff 100644 --- a/Src/RCommon.SystemTextJson/TextJsonSerializer.cs +++ b/Src/RCommon.SystemTextJson/TextJsonSerializer.cs @@ -9,62 +9,85 @@ namespace RCommon.SystemTextJson { + /// + /// Implements using the System.Text.Json library. + /// Supports per-call overrides for camel-case naming and indented formatting through + /// and . + /// + /// + /// The underlying are injected via the options pattern + /// and may be mutated per-call when options are provided. This means per-call options + /// modify the shared options instance. + /// public class TextJsonSerializer : IJsonSerializer { private JsonSerializerOptions _jsonOptions; + /// + /// Initializes a new instance of with the configured + /// . + /// + /// The injected System.Text.Json serializer options. public TextJsonSerializer(IOptions options) { _jsonOptions = options.Value; } - public T Deserialize(string json, JsonDeserializeOptions? options = null) + /// + public T? Deserialize(string json, JsonDeserializeOptions? options = null) { if (options != null) { + // Apply camelCase naming policy when requested if (options.CamelCase) { _jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; } } - + return JsonSerializer.Deserialize(json, _jsonOptions); } - public object Deserialize(string json, Type type, JsonDeserializeOptions? options = null) + /// + public object? Deserialize(string json, Type type, JsonDeserializeOptions? options = null) { if (options != null) { + // Apply camelCase naming policy when requested if (options.CamelCase) { _jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; } } - + return JsonSerializer.Deserialize(json, type, _jsonOptions); } + /// public string Serialize(object obj, JsonSerializeOptions? options = null) { if (options != null) { _jsonOptions.WriteIndented = options.Indented; + // Apply camelCase naming policy when requested if (options.CamelCase) { _jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; } } - + return JsonSerializer.Serialize(obj, _jsonOptions); } + /// public string Serialize(object obj, Type type, JsonSerializeOptions? options = null) { if (options != null) { _jsonOptions.WriteIndented = options.Indented; + // Apply camelCase naming policy when requested if (options.CamelCase) { _jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; diff --git a/Src/RCommon.Web/RCommon.Web.csproj b/Src/RCommon.Web/RCommon.Web.csproj index 821dd168..f61aef29 100644 --- a/Src/RCommon.Web/RCommon.Web.csproj +++ b/Src/RCommon.Web/RCommon.Web.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + enable True RCommon.Web https://rcommon.com diff --git a/Src/RCommon.Web/README.md b/Src/RCommon.Web/README.md index 71afaf58..c0bda255 100644 --- a/Src/RCommon.Web/README.md +++ b/Src/RCommon.Web/README.md @@ -1,3 +1,36 @@ # RCommon.Web -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +A foundational package for ASP.NET Core web applications in the RCommon ecosystem. This package serves as a base for web-related utilities, attributes, and extensions that integrate with the RCommon infrastructure. + +## Features + +- Base package for RCommon ASP.NET Core web integration +- Targets .NET 8, .NET 9, and .NET 10 +- Designed to host shared web utilities such as custom attributes, middleware, and extensions + +## Installation + +```shell +dotnet add package RCommon.Web +``` + +## Usage + +Add the package reference to your ASP.NET Core project: + +```xml + +``` + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Authorization.Web](https://www.nuget.org/packages/RCommon.Authorization.Web) - Swagger/OpenAPI authorization filters for ASP.NET Core +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions and builder infrastructure + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Wolverine/IWolverineEventHandlingBuilder.cs b/Src/RCommon.Wolverine/IWolverineEventHandlingBuilder.cs index b50ce1f7..5867b3bd 100644 --- a/Src/RCommon.Wolverine/IWolverineEventHandlingBuilder.cs +++ b/Src/RCommon.Wolverine/IWolverineEventHandlingBuilder.cs @@ -3,6 +3,10 @@ namespace RCommon.Wolverine { + /// + /// Builder interface for configuring Wolverine-based event handling within the RCommon framework. + /// Extends to provide Wolverine-specific event subscription capabilities. + /// public interface IWolverineEventHandlingBuilder : IEventHandlingBuilder { } diff --git a/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs b/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs index 314a1c12..661e7d09 100644 --- a/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs +++ b/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs @@ -12,6 +12,14 @@ namespace RCommon.Wolverine.Producers { + /// + /// An implementation that publishes events to all subscribed handlers + /// using Wolverine's method (fan-out pattern). + /// + /// + /// Use this producer when you want an event to be delivered to all Wolverine handlers subscribed + /// to the event type. For point-to-point delivery, use instead. + /// public class PublishWithWolverineEventProducer : IEventProducer { private readonly IMessageBus _messageBus; @@ -19,6 +27,13 @@ public class PublishWithWolverineEventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The Wolverine message bus used to publish events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public PublishWithWolverineEventProducer(IMessageBus messageBus, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -28,18 +43,22 @@ public PublishWithWolverineEventProducer(IMessageBus messageBus, ILogger public async Task ProduceEventAsync(T @event, CancellationToken cancellationToken = default) where T : ISerializableEvent { try { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(T))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", new object[] { this.GetGenericTypeName(), typeof(T).Name }); return; } + + // Create a scoped service context for the publish operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs b/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs index 7019433d..ee35b6bd 100644 --- a/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs +++ b/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs @@ -11,6 +11,14 @@ namespace RCommon.Wolverine.Producers { + /// + /// An implementation that sends events to a single handler endpoint + /// using Wolverine's method (point-to-point pattern). + /// + /// + /// Use this producer for command-style messaging where only one handler should process the event. + /// For fan-out delivery to all handlers, use instead. + /// public class SendWithWolverineEventProducer : IEventProducer { private readonly IMessageBus _messageBus; @@ -18,6 +26,13 @@ public class SendWithWolverineEventProducer : IEventProducer private readonly IServiceProvider _serviceProvider; private readonly EventSubscriptionManager _subscriptionManager; + /// + /// Initializes a new instance of . + /// + /// The Wolverine message bus used to send events. + /// Logger for diagnostic output. + /// Service provider for creating scoped services during event production. + /// Manages event-to-producer subscriptions for routing decisions. public SendWithWolverineEventProducer(IMessageBus messageBus, ILogger logger, IServiceProvider serviceProvider, EventSubscriptionManager subscriptionManager) { @@ -27,18 +42,22 @@ public SendWithWolverineEventProducer(IMessageBus messageBus, ILogger public async Task ProduceEventAsync(T @event, CancellationToken cancellationToken = default) where T : ISerializableEvent { try { Guard.IsNotNull(@event, nameof(@event)); + // Check if this event type is subscribed to this producer; skip if not routed here if (!_subscriptionManager.ShouldProduceEvent(this.GetType(), typeof(T))) { _logger.LogDebug("{0} skipping event {1} - not subscribed to this producer", new object[] { this.GetGenericTypeName(), typeof(T).Name }); return; } + + // Create a scoped service context for the send operation using (IServiceScope scope = _serviceProvider.CreateScope()) { if (_logger.IsEnabled(LogLevel.Information)) diff --git a/Src/RCommon.Wolverine/README.md b/Src/RCommon.Wolverine/README.md index 5f00b68d..9e934672 100644 --- a/Src/RCommon.Wolverine/README.md +++ b/Src/RCommon.Wolverine/README.md @@ -1,3 +1,86 @@ - # RCommon.Wolverine +# RCommon.Wolverine -A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more +Integrates [Wolverine](https://wolverinefx.net/) messaging with RCommon's event handling system, allowing you to produce and consume events through Wolverine's `IMessageBus` while programming against RCommon's `IEventProducer` and `ISubscriber` abstractions. + +## Features + +- Publish events to all subscribed handlers using Wolverine's fan-out (publish) semantics +- Send events to a single handler endpoint using Wolverine's point-to-point (send) semantics +- Bridge Wolverine message handlers to RCommon's `ISubscriber` abstraction for handler portability +- Event subscription routing ensures events are delivered only to their configured producers +- Factory delegate support for subscriber registration + +## Installation + +```shell +dotnet add package RCommon.Wolverine +``` + +## Usage + +```csharp +using RCommon; +using RCommon.Wolverine; + +builder.Services.AddRCommon() + .WithEventHandling(eventHandling => + { + // Register subscribers that bridge Wolverine to RCommon + eventHandling.AddSubscriber(); + + // Or register with a factory delegate + eventHandling.AddSubscriber( + sp => new OrderShippedEventHandler(sp.GetRequiredService>())); + }); + +// Configure Wolverine transports separately via the host builder +builder.Host.UseWolverine(opts => +{ + opts.ListenToRabbitQueue("orders"); + opts.PublishMessage().ToRabbitExchange("orders"); +}); +``` + +Produce events from application code: + +```csharp +public class OrderService +{ + private readonly IEventProducer _eventProducer; + + public OrderService(IEventProducer eventProducer) + { + _eventProducer = eventProducer; + } + + public async Task CreateOrderAsync(Order order) + { + // This publishes via Wolverine's IMessageBus to all subscribed handlers + await _eventProducer.ProduceEventAsync(new OrderCreatedEvent(order)); + } +} +``` + +## Key Types + +| Type | Description | +|------|-------------| +| `PublishWithWolverineEventProducer` | `IEventProducer` that publishes events to all handlers via `IMessageBus.PublishAsync` (fan-out) | +| `SendWithWolverineEventProducer` | `IEventProducer` that sends events to a single endpoint via `IMessageBus.SendAsync` (point-to-point) | +| `WolverineEventHandler` | Wolverine `IWolverineHandler` that delegates to an RCommon `ISubscriber` | +| `IWolverineEventHandlingBuilder` | Builder interface for configuring Wolverine event handling within RCommon | +| `WolverineEventHandlingBuilder` | Default builder implementation for configuring Wolverine event handling | + +## Documentation + +For full documentation, visit [rcommon.com](https://rcommon.com). + +## Related Packages + +- [RCommon.Core](https://www.nuget.org/packages/RCommon.Core) - Core abstractions including `IEventProducer` and `ISubscriber` +- [RCommon.MassTransit](https://www.nuget.org/packages/RCommon.MassTransit) - MassTransit integration for distributed messaging +- [RCommon.MediatR](https://www.nuget.org/packages/RCommon.MediatR) - MediatR integration for in-process event handling and mediator pattern + +## License + +Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/Src/RCommon.Wolverine/Subscribers/IWolverineEventHandler.cs b/Src/RCommon.Wolverine/Subscribers/IWolverineEventHandler.cs index 562f8182..95ae3a54 100644 --- a/Src/RCommon.Wolverine/Subscribers/IWolverineEventHandler.cs +++ b/Src/RCommon.Wolverine/Subscribers/IWolverineEventHandler.cs @@ -8,13 +8,26 @@ namespace RCommon.MassTransit.Subscribers { + /// + /// Non-generic marker interface for Wolverine event handlers within the RCommon framework. + /// public interface IWolverineEventHandler { } + /// + /// Generic interface for Wolverine event handlers that process a specific distributed event type. + /// + /// The distributed event type to handle. Must implement . public interface IWolverineEventHandler : IWolverineEventHandler where TDistributedEvent : class, ISerializableEvent { + /// + /// Handles the distributed event asynchronously. + /// + /// The event to handle. + /// Optional cancellation token. + /// A task representing the asynchronous operation. Task HandleAsync(TDistributedEvent distributedEvent, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs b/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs index 48d55d02..1601a21e 100644 --- a/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs +++ b/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs @@ -10,18 +10,29 @@ namespace RCommon.MassTransit.Subscribers { + /// + /// Wolverine handler that bridges Wolverine message handling to the RCommon abstraction. + /// Implements both and Wolverine's . + /// + /// The event type to handle. Must implement . public class WolverineEventHandler : IWolverineEventHandler, IWolverineHandler where TEvent : class, ISerializableEvent { private readonly ISubscriber _subscriber; private readonly ILogger> _logger; + /// + /// Initializes a new instance of . + /// + /// The RCommon subscriber that handles the event. + /// Logger for diagnostic output. public WolverineEventHandler(ISubscriber subscriber, ILogger> logger) { _subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// public async Task HandleAsync(TEvent @event, CancellationToken cancellationToken = default) { _logger.LogDebug("{0} handling event {1}", new object[] { this.GetGenericTypeName(), @event.GetGenericTypeName() }); diff --git a/Src/RCommon.Wolverine/WolverineEventHandlingBuilder.cs b/Src/RCommon.Wolverine/WolverineEventHandlingBuilder.cs index 470b1e37..104107b1 100644 --- a/Src/RCommon.Wolverine/WolverineEventHandlingBuilder.cs +++ b/Src/RCommon.Wolverine/WolverineEventHandlingBuilder.cs @@ -8,13 +8,22 @@ namespace RCommon.Wolverine { + /// + /// Default implementation of that configures + /// Wolverine event handling services through the RCommon builder pipeline. + /// public class WolverineEventHandlingBuilder : IWolverineEventHandlingBuilder { + /// + /// Initializes a new instance of using the provided RCommon builder. + /// + /// The whose service collection is used for dependency registration. public WolverineEventHandlingBuilder(IRCommonBuilder builder) { Services = builder.Services; } + /// public IServiceCollection Services { get; } } } diff --git a/Src/RCommon.Wolverine/WolverineEventHandlingBuilderExtensions.cs b/Src/RCommon.Wolverine/WolverineEventHandlingBuilderExtensions.cs index 2ade3d9e..b1b2721a 100644 --- a/Src/RCommon.Wolverine/WolverineEventHandlingBuilderExtensions.cs +++ b/Src/RCommon.Wolverine/WolverineEventHandlingBuilderExtensions.cs @@ -14,10 +14,18 @@ namespace RCommon { + /// + /// Extension methods for configuring Wolverine event handling within the RCommon builder pipeline. + /// public static class WolverineEventHandlingBuilderExtensions { - + /// + /// Registers a subscriber for a specific event type and records the event-to-producer subscription for routing. + /// + /// The event type to subscribe to. + /// The subscriber implementation that handles the event. + /// The Wolverine event handling builder. public static void AddSubscriber(this IWolverineEventHandlingBuilder builder) where TEvent : class where TEventHandler : class, ISubscriber @@ -29,6 +37,14 @@ public static void AddSubscriber(this IWolverineEventHand subscriptionManager?.AddSubscription(builder.GetType(), typeof(TEvent)); } + /// + /// Registers a subscriber for a specific event type using a factory delegate and records + /// the event-to-producer subscription for routing. + /// + /// The event type to subscribe to. + /// The subscriber implementation that handles the event. + /// The Wolverine event handling builder. + /// Factory delegate to create the subscriber from the service provider. public static void AddSubscriber(this IWolverineEventHandlingBuilder builder, Func getSubscriber) where TEvent : class where TEventHandler : class, ISubscriber diff --git a/Tests/RCommon.ApplicationServices.Tests/RCommon.ApplicationServices.Tests.csproj b/Tests/RCommon.ApplicationServices.Tests/RCommon.ApplicationServices.Tests.csproj index 5759578b..13fb3baa 100644 --- a/Tests/RCommon.ApplicationServices.Tests/RCommon.ApplicationServices.Tests.csproj +++ b/Tests/RCommon.ApplicationServices.Tests/RCommon.ApplicationServices.Tests.csproj @@ -10,22 +10,10 @@ - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/Tests/RCommon.Entities.Tests/EntityNotFoundExceptionTests.cs b/Tests/RCommon.Entities.Tests/EntityNotFoundExceptionTests.cs index b5c09d86..89b16495 100644 --- a/Tests/RCommon.Entities.Tests/EntityNotFoundExceptionTests.cs +++ b/Tests/RCommon.Entities.Tests/EntityNotFoundExceptionTests.cs @@ -298,7 +298,7 @@ public void Constructor_WithMessageAndNullInnerException_SetsOnlyMessage() var message = _faker.Lorem.Sentence(); // Act - var exception = new EntityNotFoundException(message, null); + var exception = new EntityNotFoundException(message, null!); // Assert exception.Message.Should().Be(message); diff --git a/Tests/RCommon.Persistence.Tests/DefaultDataStoreOptionsTests.cs b/Tests/RCommon.Persistence.Tests/DefaultDataStoreOptionsTests.cs index 2635977b..05de63e4 100644 --- a/Tests/RCommon.Persistence.Tests/DefaultDataStoreOptionsTests.cs +++ b/Tests/RCommon.Persistence.Tests/DefaultDataStoreOptionsTests.cs @@ -57,7 +57,7 @@ public void DefaultDataStoreName_CanBeSetToNull() options.DefaultDataStoreName = "SomeName"; // Act - options.DefaultDataStoreName = null; + options.DefaultDataStoreName = null!; // Assert options.DefaultDataStoreName.Should().BeNull(); diff --git a/Tests/RCommon.Persistence.Tests/RCommon.Persistence.Tests.csproj b/Tests/RCommon.Persistence.Tests/RCommon.Persistence.Tests.csproj index 1d8ef79a..7afb094b 100644 --- a/Tests/RCommon.Persistence.Tests/RCommon.Persistence.Tests.csproj +++ b/Tests/RCommon.Persistence.Tests/RCommon.Persistence.Tests.csproj @@ -10,23 +10,11 @@
- - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/Tests/RCommon.Persistence.Tests/RDbConnectionOptionsTests.cs b/Tests/RCommon.Persistence.Tests/RDbConnectionOptionsTests.cs index d098ef3e..fddcefe0 100644 --- a/Tests/RCommon.Persistence.Tests/RDbConnectionOptionsTests.cs +++ b/Tests/RCommon.Persistence.Tests/RDbConnectionOptionsTests.cs @@ -86,7 +86,7 @@ public void DbFactory_CanBeSetToNull() options.DbFactory = SqlClientFactory.Instance; // Act - options.DbFactory = null; + options.DbFactory = null!; // Assert options.DbFactory.Should().BeNull(); @@ -100,7 +100,7 @@ public void ConnectionString_CanBeSetToNull() options.ConnectionString = "Server=localhost;"; // Act - options.ConnectionString = null; + options.ConnectionString = null!; // Assert options.ConnectionString.Should().BeNull(); diff --git a/Tests/RCommon.Persistence.Tests/RDbConnectionTests.cs b/Tests/RCommon.Persistence.Tests/RDbConnectionTests.cs index 8e797dda..68881c6a 100644 --- a/Tests/RCommon.Persistence.Tests/RDbConnectionTests.cs +++ b/Tests/RCommon.Persistence.Tests/RDbConnectionTests.cs @@ -62,7 +62,7 @@ public void GetDbConnection_WhenOptionsNotConfigured_ThrowsRDbConnectionExceptio public void GetDbConnection_WhenDbFactoryNotConfigured_ThrowsRDbConnectionException() { // Arrange - _options.DbFactory = null; + _options.DbFactory = null!; _options.ConnectionString = "Server=localhost;Database=Test;"; var connection = new RDbConnection(_mockOptions.Object); @@ -80,7 +80,7 @@ public void GetDbConnection_WhenConnectionStringNotConfigured_ThrowsRDbConnectio { // Arrange _options.DbFactory = SqlClientFactory.Instance; - _options.ConnectionString = null; + _options.ConnectionString = null!; var connection = new RDbConnection(_mockOptions.Object); diff --git a/Tests/RCommon.TestBase.Data/RCommon.TestBase.Data.csproj b/Tests/RCommon.TestBase.Data/RCommon.TestBase.Data.csproj index 1db69f1e..8eae7763 100644 --- a/Tests/RCommon.TestBase.Data/RCommon.TestBase.Data.csproj +++ b/Tests/RCommon.TestBase.Data/RCommon.TestBase.Data.csproj @@ -7,7 +7,6 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -20,10 +19,8 @@ - - diff --git a/Tests/RCommon.TestBase.Data/TestRepository.cs b/Tests/RCommon.TestBase.Data/TestRepository.cs index e56685b9..8141ea0d 100644 --- a/Tests/RCommon.TestBase.Data/TestRepository.cs +++ b/Tests/RCommon.TestBase.Data/TestRepository.cs @@ -18,7 +18,7 @@ public class TestRepository { readonly DbContext _context; readonly IList> _entityDeleteActions; - private IDataStoreFactory _dataStoreFactory; + private IDataStoreFactory _dataStoreFactory = default!; public TestRepository(DbContext context) { @@ -28,7 +28,7 @@ public TestRepository(DbContext context) public TestRepository(IServiceProvider serviceProvider) { - _dataStoreFactory = serviceProvider.GetService(); + _dataStoreFactory = serviceProvider.GetRequiredService(); _context = _dataStoreFactory.Resolve("TestDbContext"); _entityDeleteActions = new List>(); } diff --git a/Tests/RCommon.TestBase/Entities/Customer.cs b/Tests/RCommon.TestBase/Entities/Customer.cs index 0a69b242..6ce844c6 100644 --- a/Tests/RCommon.TestBase/Entities/Customer.cs +++ b/Tests/RCommon.TestBase/Entities/Customer.cs @@ -13,13 +13,13 @@ namespace RCommon.TestBase.Entities // Customers public partial class Customer : BusinessEntity { - public string StreetAddress1 { get; set; } // StreetAddress1 (length: 255) - public string StreetAddress2 { get; set; } // StreetAddress2 (length: 255) - public string City { get; set; } // City (length: 255) - public string State { get; set; } // State (length: 255) - public string ZipCode { get; set; } // ZipCode (length: 255) - public string FirstName { get; set; } // FirstName (length: 255) - public string LastName { get; set; } // LastName (length: 255) + public string StreetAddress1 { get; set; } = string.Empty; // StreetAddress1 (length: 255) + public string StreetAddress2 { get; set; } = string.Empty; // StreetAddress2 (length: 255) + public string City { get; set; } = string.Empty; // City (length: 255) + public string State { get; set; } = string.Empty; // State (length: 255) + public string ZipCode { get; set; } = string.Empty; // ZipCode (length: 255) + public string FirstName { get; set; } = string.Empty; // FirstName (length: 255) + public string LastName { get; set; } = string.Empty; // LastName (length: 255) // Reverse navigation diff --git a/Tests/RCommon.TestBase/Entities/Department.cs b/Tests/RCommon.TestBase/Entities/Department.cs index 42974c34..45d4068d 100644 --- a/Tests/RCommon.TestBase/Entities/Department.cs +++ b/Tests/RCommon.TestBase/Entities/Department.cs @@ -11,7 +11,7 @@ namespace RCommon.TestBase.Entities // Departments public partial class Department : BusinessEntity { - public string Name { get; set; } // Name (length: 255) + public string Name { get; set; } = string.Empty; // Name (length: 255) // Reverse navigation diff --git a/Tests/RCommon.TestBase/Entities/MonthlySalesSummary.cs b/Tests/RCommon.TestBase/Entities/MonthlySalesSummary.cs index 014c3841..50be91dc 100644 --- a/Tests/RCommon.TestBase/Entities/MonthlySalesSummary.cs +++ b/Tests/RCommon.TestBase/Entities/MonthlySalesSummary.cs @@ -15,9 +15,9 @@ public partial class MonthlySalesSummary : BusinessEntity public int Month { get; set; } // Month (Primary key) public int SalesPersonId { get; set; } // SalesPersonId (Primary key) public decimal? Amount { get; set; } // Amount - public string Currency { get; set; } // Currency (length: 255) - public string SalesPersonFirstName { get; set; } // SalesPersonFirstName (length: 255) - public string SalesPersonLastName { get; set; } // SalesPersonLastName (length: 255) + public string Currency { get; set; } = string.Empty; // Currency (length: 255) + public string SalesPersonFirstName { get; set; } = string.Empty; // SalesPersonFirstName (length: 255) + public string SalesPersonLastName { get; set; } = string.Empty; // SalesPersonLastName (length: 255) public MonthlySalesSummary() { diff --git a/Tests/RCommon.TestBase/Entities/Order.cs b/Tests/RCommon.TestBase/Entities/Order.cs index b30ab36c..978ff537 100644 --- a/Tests/RCommon.TestBase/Entities/Order.cs +++ b/Tests/RCommon.TestBase/Entities/Order.cs @@ -27,7 +27,7 @@ public partial class Order : BusinessEntity /// /// Parent Customer pointed by [Orders].([CustomerId]) (FK_Customer_Orders) /// - public virtual Customer Customer { get; set; } // FK_Customer_Orders + public virtual Customer? Customer { get; set; } // FK_Customer_Orders public Order() { diff --git a/Tests/RCommon.TestBase/Entities/OrderItem.cs b/Tests/RCommon.TestBase/Entities/OrderItem.cs index 7249cbdf..c8cd6ba2 100644 --- a/Tests/RCommon.TestBase/Entities/OrderItem.cs +++ b/Tests/RCommon.TestBase/Entities/OrderItem.cs @@ -14,7 +14,7 @@ public partial class OrderItem : BusinessEntity public int OrderItemId { get; set; } // OrderItemID (Primary key) public decimal? Price { get; set; } // Price public int? Quantity { get; set; } // Quantity - public string Store { get; set; } // Store (length: 255) + public string Store { get; set; } = string.Empty; // Store (length: 255) public int? ProductId { get; set; } // ProductId public int? OrderId { get; set; } // OrderId @@ -23,12 +23,12 @@ public partial class OrderItem : BusinessEntity /// /// Parent Order pointed by [OrderItems].([OrderId]) (FK_Orders_OrderItems) /// - public virtual Order Order { get; set; } // FK_Orders_OrderItems + public virtual Order? Order { get; set; } // FK_Orders_OrderItems /// /// Parent Product pointed by [OrderItems].([ProductId]) (FK_OrderItems_Product) /// - public virtual Product Product { get; set; } // FK_OrderItems_Product + public virtual Product? Product { get; set; } // FK_OrderItems_Product public OrderItem() { diff --git a/Tests/RCommon.TestBase/Entities/Product.cs b/Tests/RCommon.TestBase/Entities/Product.cs index 55ac0551..9eca14b0 100644 --- a/Tests/RCommon.TestBase/Entities/Product.cs +++ b/Tests/RCommon.TestBase/Entities/Product.cs @@ -12,8 +12,8 @@ namespace RCommon.TestBase.Entities public partial class Product : BusinessEntity { public int ProductId { get; set; } // ProductID (Primary key) - public string Name { get; set; } // Name (length: 255) - public string Description { get; set; } // Description (length: 255) + public string Name { get; set; } = string.Empty; // Name (length: 255) + public string Description { get; set; } = string.Empty; // Description (length: 255) // Reverse navigation diff --git a/Tests/RCommon.TestBase/Entities/SalesPerson.cs b/Tests/RCommon.TestBase/Entities/SalesPerson.cs index 652184a8..bb29dbd7 100644 --- a/Tests/RCommon.TestBase/Entities/SalesPerson.cs +++ b/Tests/RCommon.TestBase/Entities/SalesPerson.cs @@ -11,8 +11,8 @@ namespace RCommon.TestBase.Entities // SalesPerson public partial class SalesPerson : BusinessEntity { - public string FirstName { get; set; } // FirstName (length: 255) - public string LastName { get; set; } // LastName (length: 255) + public string FirstName { get; set; } = string.Empty; // FirstName (length: 255) + public string LastName { get; set; } = string.Empty; // LastName (length: 255) public float? SalesQuota { get; set; } // SalesQuota public decimal? SalesYtd { get; set; } // SalesYTD public int? DepartmentId { get; set; } // DepartmentId @@ -23,12 +23,12 @@ public partial class SalesPerson : BusinessEntity /// /// Parent Department pointed by [SalesPerson].([DepartmentId]) (FK74214A90E25FF6) /// - public virtual Department Department { get; set; } // FK74214A90E25FF6 + public virtual Department? Department { get; set; } // FK74214A90E25FF6 /// /// Parent SalesTerritory pointed by [SalesPerson].([TerritoryId]) (FK74214A90B23DB0A3) /// - public virtual SalesTerritory SalesTerritory { get; set; } // FK74214A90B23DB0A3 + public virtual SalesTerritory? SalesTerritory { get; set; } // FK74214A90B23DB0A3 public SalesPerson() { diff --git a/Tests/RCommon.TestBase/Entities/SalesTerritory.cs b/Tests/RCommon.TestBase/Entities/SalesTerritory.cs index 9759c19e..22669e97 100644 --- a/Tests/RCommon.TestBase/Entities/SalesTerritory.cs +++ b/Tests/RCommon.TestBase/Entities/SalesTerritory.cs @@ -11,8 +11,8 @@ namespace RCommon.TestBase.Entities // SalesTerritory public partial class SalesTerritory : BusinessEntity { - public string Name { get; set; } // Name (length: 255) - public string Description { get; set; } // Description (length: 255) + public string Name { get; set; } = string.Empty; // Name (length: 255) + public string Description { get; set; } = string.Empty; // Description (length: 255) // Reverse navigation diff --git a/Tests/RCommon.TestBase/RCommon.TestBase.csproj b/Tests/RCommon.TestBase/RCommon.TestBase.csproj index 3dffe30b..1aedafb0 100644 --- a/Tests/RCommon.TestBase/RCommon.TestBase.csproj +++ b/Tests/RCommon.TestBase/RCommon.TestBase.csproj @@ -7,16 +7,13 @@ - - - diff --git a/Tests/RCommon.TestBase/TestBootstrapper.cs b/Tests/RCommon.TestBase/TestBootstrapper.cs index ea2e85d0..53f5d0fb 100644 --- a/Tests/RCommon.TestBase/TestBootstrapper.cs +++ b/Tests/RCommon.TestBase/TestBootstrapper.cs @@ -18,8 +18,8 @@ namespace RCommon.TestBase { public abstract class TestBootstrapper { - private ServiceProvider _serviceProvider; - private Microsoft.Extensions.Logging.ILogger _logger; + private ServiceProvider _serviceProvider = default!; + private Microsoft.Extensions.Logging.ILogger _logger = default!; public TestBootstrapper() { @@ -67,7 +67,7 @@ protected Mock CreateMockHttpClient(HttpResponseMessage mock return mockFactory; } - public IConfigurationRoot Configuration { get; private set; } + public IConfigurationRoot Configuration { get; private set; } = default!; public ServiceProvider ServiceProvider { get => _serviceProvider; set => _serviceProvider = value; } public Microsoft.Extensions.Logging.ILogger Logger { get => _logger; set => _logger = value; } }