diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..e6df774 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "husky": { + "version": "0.7.1", + "commands": [ + "husky" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 57c8b2e..23a9fe2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,10 +30,10 @@ dotnet_sort_system_directives_first = true file_header_template = unset # this. and Me. preferences -dotnet_style_qualification_for_event = false:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_event = true:warning +dotnet_style_qualification_for_field = true:warning +dotnet_style_qualification_for_method = true:warning +dotnet_style_qualification_for_property = true:warning # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:silent @@ -78,9 +78,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none [*.cs] # var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:warning # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent @@ -174,6 +174,7 @@ csharp_style_namespace_declarations = block_scoped:silent csharp_style_prefer_method_group_conversion = true:silent csharp_style_prefer_top_level_statements = true:silent csharp_style_prefer_primary_constructors = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion #### Naming styles #### [*.{cs,vb}] diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c8c2dea --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# lint project based on editorconfig rules +dotnet husky run --group pre-commit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..63f2594 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# build project and run unit tests +dotnet husky run --group pre-push diff --git a/.husky/task-runner.json b/.husky/task-runner.json new file mode 100644 index 0000000..57978a0 --- /dev/null +++ b/.husky/task-runner.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://alirezanet.github.io/Husky.Net/schema.json", + "tasks": [ + { + "name": "dotnet-format", + "group": "pre-commit", + "command": "dotnet", + "args": [ + "format", + "--include", + "${staged}", + "--verbosity", + "diagnostic" + ], + "include": [ + "**/*.cs", + "**/*.ps1" + ] + }, + { + "name": "dotnet-build", + "group": "pre-push", + "command": "dotnet", + "args": [ + "build", + "/warnaserror" + ] + }, + { + "name": "dotnet-test", + "group": "pre-push", + "command": "dotnet", + "args": [ + "test", + "--nologo" + ] + } + ] +} diff --git a/AdvancedSystems.Connector.Abstractions/AdvancedSystems.Connector.Abstractions.csproj b/AdvancedSystems.Connector.Abstractions/AdvancedSystems.Connector.Abstractions.csproj index e746157..02e1401 100644 --- a/AdvancedSystems.Connector.Abstractions/AdvancedSystems.Connector.Abstractions.csproj +++ b/AdvancedSystems.Connector.Abstractions/AdvancedSystems.Connector.Abstractions.csproj @@ -3,7 +3,7 @@ net8.0 0.0.0-alpha - TODO + Abstractions for AdvancedSystems.Connector. AdvancedSystems.Connector.Abstractions AdvancedSystems.Connector.Abstractions Advanced Systems Connector Abstractions Library diff --git a/AdvancedSystems.Connector.Abstractions/DatabaseCommandType.cs b/AdvancedSystems.Connector.Abstractions/DatabaseCommandType.cs deleted file mode 100644 index 6cbee71..0000000 --- a/AdvancedSystems.Connector.Abstractions/DatabaseCommandType.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace AdvancedSystems.Connector.Abstractions; - -/// -/// Specifies how a command string is interpreted. -/// -public enum DatabaseCommandType -{ - /// - /// An SQL command. - /// - Text, - /// - /// The name of a stored procedure. - /// - StoredProcedure -} diff --git a/AdvancedSystems.Connector.Abstractions/Provider.cs b/AdvancedSystems.Connector.Abstractions/DbProvider.cs similarity index 50% rename from AdvancedSystems.Connector.Abstractions/Provider.cs rename to AdvancedSystems.Connector.Abstractions/DbProvider.cs index ae0b0dc..7ff8f9f 100644 --- a/AdvancedSystems.Connector.Abstractions/Provider.cs +++ b/AdvancedSystems.Connector.Abstractions/DbProvider.cs @@ -1,12 +1,17 @@ -namespace AdvancedSystems.Connector; +namespace AdvancedSystems.Connector.Abstractions; /// /// Represents the different database providers supported by the system. /// -public enum Provider +public enum DbProvider { + /// + /// Generic SQL Server. + /// + Generic = 0, + /// /// Microsoft SQL Server. /// - MsSql, -} + MsSql = 1, +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandException.cs b/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandException.cs new file mode 100644 index 0000000..095c756 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandException.cs @@ -0,0 +1,42 @@ +using System; + +namespace AdvancedSystems.Connector.Abstractions.Exceptions; + +/// +/// Represents errors that occur during the instantiation or execution of a database command. +/// +public class DbCommandException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public DbCommandException() + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error . + /// + /// + /// The message that describes the error. + /// + public DbCommandException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error and a reference to the + /// exception that is the cause of this exception. + /// + /// + /// The message that describes the error. + /// + /// + /// The exception that is the cause of the current exception. + /// + public DbCommandException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandExecutionException.cs b/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandExecutionException.cs deleted file mode 100644 index 5d1cdca..0000000 --- a/AdvancedSystems.Connector.Abstractions/Exceptions/DbCommandExecutionException.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; - -namespace AdvancedSystems.Connector.Abstractions.Exceptions; - -/// -/// Represents an error that occurs during the execution of a database command. -/// -public class DbCommandExecutionException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public DbCommandExecutionException() - { - - } - - /// - /// Initializes a new instance of the class - /// with a specified error . - /// - /// - /// The error message that explains the reason for the exception. - /// - public DbCommandExecutionException(string message) : base(message) - { - - } - - /// - /// Initializes a new instance of the class - /// with a specified error a reference to the - /// exception that is the cause of this exception. - /// - /// - /// The error message that explains the reason for the exception. - /// - /// - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - /// - public DbCommandExecutionException(string message, Exception inner) : base(message, inner) - { - - } -} diff --git a/AdvancedSystems.Connector.Abstractions/Exceptions/DbConnectionException.cs b/AdvancedSystems.Connector.Abstractions/Exceptions/DbConnectionException.cs index e5111cc..fd01fe5 100644 --- a/AdvancedSystems.Connector.Abstractions/Exceptions/DbConnectionException.cs +++ b/AdvancedSystems.Connector.Abstractions/Exceptions/DbConnectionException.cs @@ -1,11 +1,12 @@ using System; +using System.Data.Common; namespace AdvancedSystems.Connector.Abstractions.Exceptions; /// -/// Represents an error that occurs during the communication with a database. +/// Represents an exception that occurs when a database connection fails. /// -public class DbConnectionException : Exception +public class DbConnectionException : DbException { /// /// Initializes a new instance of the class. @@ -17,10 +18,10 @@ public DbConnectionException() /// /// Initializes a new instance of the class - /// with a specified error . + /// with the specified error . /// /// - /// The error message that explains the reason for the exception. + /// The message to display for this exception. /// public DbConnectionException(string message) : base(message) { @@ -29,17 +30,17 @@ public DbConnectionException(string message) : base(message) /// /// Initializes a new instance of the class - /// with a specified error a reference to the - /// exception that is the cause of this exception. + /// with the specified error and a reference to the + /// that is cause of this exception. /// /// - /// The error message that explains the reason for the exception. + /// The message to display for this exception. /// - /// - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// + /// The inner exception reference. /// - public DbConnectionException(string message, Exception inner) : base(message, inner) + public DbConnectionException(string message, Exception innerException) : base(message, innerException) { } -} +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDatabaseCommand.cs b/AdvancedSystems.Connector.Abstractions/IDatabaseCommand.cs deleted file mode 100644 index 141bed7..0000000 --- a/AdvancedSystems.Connector.Abstractions/IDatabaseCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; - -namespace AdvancedSystems.Connector.Abstractions; - -/// -/// Defines an SQL statement or stored procedure to execute against a data source. -/// -public interface IDatabaseCommand -{ - #region Properties - - /// - /// Gets or sets the Transact-SQL statement or stored - /// procedure to execute at the data source. - /// - string CommandText { get; set; } - - /// - /// Gets or sets a value indicating how the - /// property is to be interpreted. - /// - DatabaseCommandType CommandType { get; set; } - - /// - /// Gets a collection of parameters. - /// - List Parameters { get; } - - #endregion - - #region Methods - - /// - /// Adds a to . - /// - /// The parameter to add. - void AddParameter(IDatabaseParameter parameter); - - /// - /// Adds a to . - /// - /// The type of the parameter to add. - /// The name of the parameter to add. - /// The value of the parameter to add. - void AddParameter(string name, T value); - - #endregion -} diff --git a/AdvancedSystems.Connector.Abstractions/IDatabaseConnectionFactory.cs b/AdvancedSystems.Connector.Abstractions/IDatabaseConnectionFactory.cs deleted file mode 100644 index 8a95485..0000000 --- a/AdvancedSystems.Connector.Abstractions/IDatabaseConnectionFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace AdvancedSystems.Connector.Abstractions; - -/// -/// Defines a factory for creating instances of . -/// -public interface IDatabaseConnectionFactory -{ - /// - /// Creates a new instance of based on the - /// specified provider. - /// - /// - /// The database provider for which the connection service should be created. - /// - /// - /// An instance of configured for the - /// specified provider. - /// - /// - /// Thrown when the specified is not supported by the factory. - /// - IDatabaseConnectionService Create(Provider provider); -} diff --git a/AdvancedSystems.Connector.Abstractions/IDatabaseParameter.cs b/AdvancedSystems.Connector.Abstractions/IDatabaseParameter.cs deleted file mode 100644 index ef6f365..0000000 --- a/AdvancedSystems.Connector.Abstractions/IDatabaseParameter.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Data; - -namespace AdvancedSystems.Connector.Abstractions; - -public interface IDatabaseParameter -{ - #region Properties - - string ParameterName { get; set; } - - SqlDbType SqlDbType { get; set; } - - object? Value { get; set; } - - #endregion -} diff --git a/AdvancedSystems.Connector.Abstractions/IDbConnection.cs b/AdvancedSystems.Connector.Abstractions/IDbConnection.cs new file mode 100644 index 0000000..04751b7 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbConnection.cs @@ -0,0 +1,40 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace AdvancedSystems.Connector.Abstractions; + +public interface IDbConnection : IDisposable where T : class, IDbSettings, new() +{ + #region Properties + + T Options { get; } + + string ConnectionString { get; } + + /// + /// + /// + /// + /// The values in this enumeration are not designed to be used as a set of flags. + /// + ConnectionState ConnectionState { get; } + + #endregion + + #region Methods + + string ToString(); + + DataSet ExecuteQuery(DbCommand dbCommand); + + ValueTask ExecuteQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default); + + int ExecuteNonQuery(DbCommand dbCommand); + + ValueTask ExecuteNonQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default); + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbConnectionFactory.cs b/AdvancedSystems.Connector.Abstractions/IDbConnectionFactory.cs new file mode 100644 index 0000000..cd56eb6 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbConnectionFactory.cs @@ -0,0 +1,33 @@ +using System; +using System.Data.Common; + +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines a factory for creating instances of . +/// +public interface IDbConnectionFactory +{ + #region Methods + + /// + /// Creates a new instance of based on the + /// specified provider. + /// + /// + /// The database provider for which the connection service should be created. + /// + /// + /// The connection string used to establish the connection with the database. + /// + /// + /// An instance of configured for the + /// specified provider. + /// + /// + /// Thrown when the specified is not implemented by the factory yet. + /// + DbConnection Create(DbProvider dbProvider, string connectionString); + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDatabaseConnectionService.cs b/AdvancedSystems.Connector.Abstractions/IDbConnectionService.cs similarity index 76% rename from AdvancedSystems.Connector.Abstractions/IDatabaseConnectionService.cs rename to AdvancedSystems.Connector.Abstractions/IDbConnectionService.cs index 249785b..fc241a7 100644 --- a/AdvancedSystems.Connector.Abstractions/IDatabaseConnectionService.cs +++ b/AdvancedSystems.Connector.Abstractions/IDbConnectionService.cs @@ -1,40 +1,43 @@ using System.Data; -using System.Threading.Tasks; +using System.Data.Common; using System.Threading; +using System.Threading.Tasks; using AdvancedSystems.Connector.Abstractions.Exceptions; namespace AdvancedSystems.Connector.Abstractions; /// -/// Defines the contract for a service responsible for managing database connections. +/// Defines a contract for a service responsible for managing database connections. /// /// /// The connection is managed internally and will be closed after the query execution is complete. /// -public interface IDatabaseConnectionService +/// +/// The connection options. +/// +public interface IDbConnectionService where T : class, IDbSettings, new() { #region Properties /// - /// Gets or sets the string used to open a SQL Server database. + /// /// - string ConnectionString { get; } + ConnectionState ConnectionState { get; } /// - /// Indicates the state of the connection during the most recent network - /// operation performed on the connection. + /// The connection options. /// - ConnectionState ConnectionState { get; } + T Options { get; } #endregion #region Methods /// - /// Executes a query defined by the specified synchronously. + /// Executes a query defined by the specified synchronously. /// - /// + /// /// The command that defines the query to be executed. It must contain the SQL command /// text and any required parameters. /// @@ -42,7 +45,7 @@ public interface IDatabaseConnectionService /// A containing the results of the executed query. The /// may include one or more objects representing the result sets. /// - /// + /// /// Thrown when an error occurs during the execution of the SQL command, such as a syntax error /// or execution failure. /// @@ -50,12 +53,12 @@ public interface IDatabaseConnectionService /// Thrown when there is a failure in communicating with the database, such as a connection /// issue or timeout. /// - DataSet ExecuteQuery(IDatabaseCommand databaseCommand); + DataSet ExecuteQuery(DbCommand dbCommand); /// - /// Executes a query defined by the specified asynchronously. + /// Executes a query defined by the specified asynchronously. /// - /// + /// /// The command that defines the query to be executed. It must contain the SQL command /// text and any required parameters. /// @@ -66,7 +69,7 @@ public interface IDatabaseConnectionService /// A representing the asynchronous operation. The /// contains the results of the executed query. It may be null if no results are returned or if an error occurs. /// - /// + /// /// Thrown when an error occurs during the execution of the SQL command, such as a syntax error /// or execution failure. /// @@ -74,12 +77,12 @@ public interface IDatabaseConnectionService /// Thrown when there is a failure in communicating with the database, such as a connection /// issue or timeout. /// - ValueTask ExecuteQueryAsync(IDatabaseCommand databaseCommand, CancellationToken cancellationToken = default); + ValueTask ExecuteQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default); /// - /// Executes a non-query command defined by the specified synchronously. + /// Executes a non-query command defined by the specified synchronously. /// - /// + /// /// The command that defines the query to be executed. It must contain the SQL command /// text and any required parameters. /// @@ -87,7 +90,7 @@ public interface IDatabaseConnectionService /// The number of rows affected by the command. This number indicates how many rows were impacted by /// the non-query operation. /// - /// + /// /// Thrown when an error occurs during the execution of the SQL command, such as a syntax error /// or execution failure. /// @@ -95,12 +98,12 @@ public interface IDatabaseConnectionService /// Thrown when there is a failure in communicating with the database, such as a connection /// issue or timeout. /// - int ExecuteNonQuery(IDatabaseCommand databaseCommand); + int ExecuteNonQuery(DbCommand dbCommand); /// - /// Executes a non-query command defined by the specified asynchronously. + /// Executes a non-query command defined by the specified asynchronously. /// - /// + /// /// The command that defines the query to be executed. It must contain the SQL command /// text and any required parameters. /// @@ -112,7 +115,7 @@ public interface IDatabaseConnectionService /// of rows affected by the command. This indicates how many rows were impacted by the non-query /// operation. /// - /// + /// /// Thrown when an error occurs during the execution of the SQL command, such as a syntax error /// or execution failure. /// @@ -120,7 +123,7 @@ public interface IDatabaseConnectionService /// Thrown when there is a failure in communicating with the database, such as a connection /// issue or timeout. /// - ValueTask ExecuteNonQueryAsync(IDatabaseCommand databaseCommand, CancellationToken cancellationToken = default); + ValueTask ExecuteNonQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default); #endregion -} +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbConnectionServiceFactory.cs b/AdvancedSystems.Connector.Abstractions/IDbConnectionServiceFactory.cs new file mode 100644 index 0000000..1ea3898 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbConnectionServiceFactory.cs @@ -0,0 +1,58 @@ +using System; + +using AdvancedSystems.Connector.Abstractions.Exceptions; + +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines a contract for creating and managing multiple connection instances. +/// +/// +/// The factory options. +/// +/// +/// The connection options. +/// +public interface IDbConnectionServiceFactory where T : class, IDbFactorySettings, new() where U : class, IDbSettings, new() +{ + #region Properties + + /// + /// The factory options. + /// + T Options { get; } + + #endregion + + #region Methods + + /// + /// Retrieves a connection service configured for the specified . + /// + /// + /// The username of the database user for whom the connection service is to be retrieved. + /// + /// + /// Returns a connection service. + /// + /// + /// Raised if the no connection matching the search criteria could be returned. + /// + IDbConnectionService GetConnection(string dbUser); + + /// + /// Retrieves a connection service from the factory pool determined by the . + /// + /// + /// The filter to apply for retrieving a connection. + /// + /// + /// Returns a connection service. + /// + /// + /// Raised if the no connection matching the search criteria could be returned. + /// + IDbConnectionService GetConnection(Predicate predicate); + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbConnectionStringFactory.cs b/AdvancedSystems.Connector.Abstractions/IDbConnectionStringFactory.cs new file mode 100644 index 0000000..ead061d --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbConnectionStringFactory.cs @@ -0,0 +1,30 @@ +using System; + +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines a factory for creating connection strings for various . +/// +public interface IDbConnectionStringFactory +{ + #region Methods + + /// + /// Creates a database connection string based on the specified provider. + /// + /// + /// A that specifies the type of database to connect to. + /// + /// + /// A boolean value indicating whether the password in the connection string should be masked. + /// + /// + /// A database connection string. + /// + /// + /// Thrown when the specified is not implemented yet. + /// + string Create(DbProvider dbProvider, bool maskPassword = false); + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbDataAdapterFactory.cs b/AdvancedSystems.Connector.Abstractions/IDbDataAdapterFactory.cs new file mode 100644 index 0000000..2c0b397 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbDataAdapterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Data.Common; + +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines a factory for creating instances of based on the +/// specified . +/// +public interface IDbDataAdapterFactory +{ + /// + /// Creates an instance of based on the specific implementation + /// derived from . + /// + /// + /// The that will be used to determine the type of + /// to create. + /// + /// + /// An instance of that is configured to work with the type of + /// provided. + /// + /// + /// Thrown when the specified provider is not supported by the factory. + /// + DbDataAdapter Create(DbCommand dbCommand); +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbFactorySettings.cs b/AdvancedSystems.Connector.Abstractions/IDbFactorySettings.cs new file mode 100644 index 0000000..b5a565c --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbFactorySettings.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines a contract for multiple configuring connection strings through +/// . +/// +/// +/// The factory options. +/// +public interface IDbFactorySettings : IDbSettings where T : class, IDbSettings, new() +{ + #region Properties + + /// + /// Contains a list of connection options consumed by this factory. + /// + List Options { get; } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Abstractions/IDbSettings.cs b/AdvancedSystems.Connector.Abstractions/IDbSettings.cs new file mode 100644 index 0000000..9bc4e18 --- /dev/null +++ b/AdvancedSystems.Connector.Abstractions/IDbSettings.cs @@ -0,0 +1,41 @@ +namespace AdvancedSystems.Connector.Abstractions; + +/// +/// Defines common properties for creating connection strings. +/// +public interface IDbSettings +{ + #region Properties + + /// + /// Gets or sets the name of the application associated with the connection string. + /// + string? ApplicationName { get; set; } + + /// + /// Gets or sets the name or network address of the instance of SQL Server to connect to. + /// + string? DataSource { get; set; } + + /// + /// Gets or sets the name of the database associated with the connection. + /// + string? InitialCatalog { get; set; } + + /// + /// Gets or sets the password for the SQL Server account. + /// + string? Password { get; set; } + + /// + /// Gets or sets the database provider. + /// + DbProvider DbProvider { get; } + + /// + /// Gets or sets the user ID to be used when connecting to SQL Server. + /// + string? UserID { get; set; } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Tests/AdvancedSystems.Connector.Tests.csproj b/AdvancedSystems.Connector.Tests/AdvancedSystems.Connector.Tests.csproj new file mode 100644 index 0000000..75ebabf --- /dev/null +++ b/AdvancedSystems.Connector.Tests/AdvancedSystems.Connector.Tests.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + Unit test project for AdvancedSystems.Connector. + AdvancedSystems.Security.Tests + AdvancedSystems.Security.Tests + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/AdvancedSystems.Connector.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/AdvancedSystems.Connector.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..86b61e6 --- /dev/null +++ b/AdvancedSystems.Connector.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Common; +using AdvancedSystems.Connector.DependencyInjection; +using AdvancedSystems.Connector.Options; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace AdvancedSystems.Security.Tests.DependencyInjection; + +/// +/// Tests the public methods in . +/// +public sealed class ServiceCollectionExtensionsTests +{ + /// + /// Mock options for . + /// + private readonly MsSqlOptions _dbConnectionOptions; + + /// + /// Dummy decryption function to test the mechanics of calling + /// a delegate at option registration. + /// + /// + /// The password to decrypt. + /// + /// + /// The password in plaintext. + /// + private static string DecryptPassword(string password) => password.ToUpper(); + + public ServiceCollectionExtensionsTests() + { + this._dbConnectionOptions = new MsSqlOptions + { + DataSource = new DataSource("localhost", 80), + InitialCatalog = "ADVSYS", + MinPoolSize = 1, + MaxPoolSize = 3, + UserID = "admin", + Password = "admin", + ApplicationName = "Test", + }; + } + + #region AddDbConnectionService + + /// + /// Tests that can be initialized through + /// dependency injection from . + /// + [Fact] + public async Task AddDbConnectionService_Test_FromOptions() + { + // Arrange + using IHost? host = await new HostBuilder() + .ConfigureWebHostDefaults(builder => + { + builder.UseTestServer(); + + builder.ConfigureTestServices(services => + { + services.AddDbConnectionService(options => + { + options.DataSource = this._dbConnectionOptions.DataSource; + options.InitialCatalog = this._dbConnectionOptions.InitialCatalog; + options.MinPoolSize = this._dbConnectionOptions.MinPoolSize; + options.MaxPoolSize = this._dbConnectionOptions.MaxPoolSize; + options.UserID = this._dbConnectionOptions.UserID; + options.Password = this._dbConnectionOptions.Password; + options.ApplicationName = this._dbConnectionOptions.ApplicationName; + }, DecryptPassword); + }); + + builder.Configure(app => + { + + }); + }) + .StartAsync(); + + // Act + IDbConnectionService? dbConnectionService = host.Services.GetService>(); + + // Assert + Assert.Multiple(() => + { + Assert.NotNull(dbConnectionService); + Assert.True(dbConnectionService.Options.Password?.All(char.IsUpper)); + Assert.Equal(this._dbConnectionOptions.DataSource, dbConnectionService.Options.DataSource); + Assert.Equal(this._dbConnectionOptions.InitialCatalog, dbConnectionService.Options.InitialCatalog); + Assert.Equal(this._dbConnectionOptions.MinPoolSize, dbConnectionService.Options.MinPoolSize); + Assert.Equal(this._dbConnectionOptions.MaxPoolSize, dbConnectionService.Options.MaxPoolSize); + Assert.Equal(this._dbConnectionOptions.UserID, dbConnectionService.Options.UserID); + Assert.Equal(this._dbConnectionOptions.ApplicationName, dbConnectionService.Options.ApplicationName); + }); + + await host.StopAsync(); + } + + /// + /// Tests that can be initialized through + /// dependency injection from appsettings.json. + /// + [Fact] + public async Task AddDbConnectionService_Test_FromAppSettings() + { + // Arrange + var appSettings = new Dictionary() + { + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.DataSource)}", this._dbConnectionOptions.DataSource }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.InitialCatalog)}", this._dbConnectionOptions.InitialCatalog }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.MinPoolSize)}", this._dbConnectionOptions.MinPoolSize.ToString() }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.MaxPoolSize)}", this._dbConnectionOptions.MaxPoolSize.ToString() }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.UserID)}", this._dbConnectionOptions.UserID }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.Password)}", this._dbConnectionOptions.Password }, + { $"{Sections.DATABASE}:{nameof(MsSqlOptions.ApplicationName)}", this._dbConnectionOptions.ApplicationName }, + }; + + IConfigurationRoot configurationRoot = new ConfigurationBuilder() + .AddInMemoryCollection(appSettings) + .Build(); + + using IHost? host = await new HostBuilder() + .ConfigureWebHostDefaults(builder => + { + builder.UseTestServer(); + + builder.ConfigureTestServices(services => + { + services.AddDbConnectionService(configurationRoot.GetRequiredSection(Sections.DATABASE), DecryptPassword); + }); + + builder.Configure(app => + { + + }); + }) + .StartAsync(); + + // Act + IDbConnectionService? dbConnectionService = host.Services.GetService>(); + + // Assert + Assert.Multiple(() => + { + Assert.NotNull(dbConnectionService); + Assert.True(dbConnectionService.Options.Password?.All(char.IsUpper)); + Assert.Equal(this._dbConnectionOptions.DataSource, dbConnectionService.Options.DataSource); + Assert.Equal(this._dbConnectionOptions.InitialCatalog, dbConnectionService.Options.InitialCatalog); + Assert.Equal(this._dbConnectionOptions.MinPoolSize, dbConnectionService.Options.MinPoolSize); + Assert.Equal(this._dbConnectionOptions.MaxPoolSize, dbConnectionService.Options.MaxPoolSize); + Assert.Equal(this._dbConnectionOptions.UserID, dbConnectionService.Options.UserID); + Assert.Equal(this._dbConnectionOptions.ApplicationName, dbConnectionService.Options.ApplicationName); + }); + + await host.StopAsync(); + } + + #endregion + + #region AddDbConnectionServiceFactory + + /// + /// Tests that can be initialized through + /// dependency injection from and a collection of . + /// + [Fact(Skip = "TODO")] + public void AddDbConnectionServiceFactory_Test_FromOptions() + { + // Arrange + // Act + // Assert + } + + /// + /// Tests that can be initialized through + /// dependency injection from appsettings.json. + /// + [Fact(Skip = "TODO")] + public void AddDbConnectionServiceFactory_Test_FromAppSettings() + { + // Arrange + // Act + // Assert + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Tests/Fixtures/DbConnectionServiceFixture.cs b/AdvancedSystems.Connector.Tests/Fixtures/DbConnectionServiceFixture.cs new file mode 100644 index 0000000..05fd78d --- /dev/null +++ b/AdvancedSystems.Connector.Tests/Fixtures/DbConnectionServiceFixture.cs @@ -0,0 +1,85 @@ +using System.Data; +using System.Data.Common; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Common; +using AdvancedSystems.Connector.Services; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Moq; + +using DbCommand = System.Data.Common.DbCommand; + +namespace AdvancedSystems.Connector.Tests.Fixtures; + +public sealed class DbConnectionServiceFixture where T : class, IDbSettings, new() +{ + private readonly Mock _dbConnection = new(); + private readonly Mock _dbDataAdapter = new(); + + private readonly Mock>> _logger = new(); + private readonly Mock> _options = new(); + private readonly Mock _dbConnectionFactory = new(); + private readonly Mock _dbConnectionStringFactory = new(); + private readonly Mock _dbDataAdapterFactory = new(); + + public DbConnectionServiceFixture() + { + this.Options.Setup(x => x.Value) + .Returns(new T + { + ApplicationName = nameof(DbConnectionServiceFixture), + UserID = "admin", + Password = "password", + DataSource = new DataSource("localhost", 1433), + InitialCatalog = "TEST", + }); + + this.DbConnectionStringFactory.Setup(x => x.Create(It.IsAny(), true)) + .Returns(It.IsAny()); + + this.DbConnectionFactory.Setup(x => x.Create(It.IsAny(), It.IsAny())) + .Returns(this._dbConnection.Object); + + this._dbConnection.SetupAdd(x => x.StateChange += It.IsAny()) + .Callback((StateChangeEventHandler handler) => + { + handler(this._dbConnection.Object, new StateChangeEventArgs(ConnectionState.Closed, ConnectionState.Open)); + }); + + this.DbDataAdapterFactory.Setup(x => x.Create(It.IsAny())) + .Returns(this._dbDataAdapter.Object); + + this.DbConnectionService = new DbConnectionService(this.Logger.Object, this.Options.Object); + } + + #region Properties + + public Mock>> Logger => this._logger; + + public Mock> Options => this._options; + + public Mock DbConnectionFactory => this._dbConnectionFactory; + + public Mock DbConnectionStringFactory => this._dbConnectionStringFactory; + + public Mock DbDataAdapterFactory => this._dbDataAdapterFactory; + + public IDbConnectionService DbConnectionService { get; set; } + + #endregion + + #region Methods + + public void ClearInvocations() + { + this.Logger.Invocations.Clear(); + this.Options.Invocations.Clear(); + this.DbConnectionFactory.Invocations.Clear(); + this.DbConnectionStringFactory.Invocations.Clear(); + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Tests/Services/DbConnectionServiceTests.cs b/AdvancedSystems.Connector.Tests/Services/DbConnectionServiceTests.cs new file mode 100644 index 0000000..c861658 --- /dev/null +++ b/AdvancedSystems.Connector.Tests/Services/DbConnectionServiceTests.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using System.Data; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedSystems.Connector.Options; +using AdvancedSystems.Connector.Tests.Fixtures; + +using Microsoft.Data.SqlClient; + +using Moq; + +namespace AdvancedSystems.Security.Tests.Services; + +[Category(TestCategory.UNIT)] +public sealed class DbConnectionServiceTests : IClassFixture> +{ + private readonly DbConnectionServiceFixture _sut; + + public DbConnectionServiceTests(DbConnectionServiceFixture sut) + { + this._sut = sut; + } + + #region Tests + + [Fact(Skip = "TODO")] + public void ExecuteQuery_Test() + { + // Arrange + // Act + // Assert + } + + [Fact(Skip = "TODO")] + public void ExecuteQueryAsync_Test() + { + // Arrange + // Act + // Assert + } + + [Fact(Skip = "TODO")] + public void ExecuteNonQuery_Test() + { + // Arrange + // Act + // Assert + } + + [Fact(Skip = "TODO")] + public void ExecuteNonQueryAsync_Test() + { + // Arrange + // Act + // Assert + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.Tests/TestCategory.cs b/AdvancedSystems.Connector.Tests/TestCategory.cs new file mode 100644 index 0000000..b8c35b4 --- /dev/null +++ b/AdvancedSystems.Connector.Tests/TestCategory.cs @@ -0,0 +1,9 @@ +namespace AdvancedSystems.Security.Tests; + +internal static class TestCategory +{ + /// + /// Denotes a unit test category. + /// + internal const string UNIT = "UNIT"; +} \ No newline at end of file diff --git a/AdvancedSystems.Connector.sln b/AdvancedSystems.Connector.sln index e076f01..3031cd4 100644 --- a/AdvancedSystems.Connector.sln +++ b/AdvancedSystems.Connector.sln @@ -12,6 +12,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdvancedSystems.Connector.A EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdvancedSystems.Connector", "AdvancedSystems.Connector\AdvancedSystems.Connector.csproj", "{3D582FC1-A594-4438-872D-415154846C13}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdvancedSystems.Connector.Tests", "AdvancedSystems.Connector.Tests\AdvancedSystems.Connector.Tests.csproj", "{247636DB-37C4-466C-B53C-7A313AD7DD6A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,10 @@ Global {3D582FC1-A594-4438-872D-415154846C13}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D582FC1-A594-4438-872D-415154846C13}.Release|Any CPU.ActiveCfg = Release|Any CPU {3D582FC1-A594-4438-872D-415154846C13}.Release|Any CPU.Build.0 = Release|Any CPU + {247636DB-37C4-466C-B53C-7A313AD7DD6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {247636DB-37C4-466C-B53C-7A313AD7DD6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {247636DB-37C4-466C-B53C-7A313AD7DD6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {247636DB-37C4-466C-B53C-7A313AD7DD6A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AdvancedSystems.Connector/AdvancedSystems.Connector.csproj b/AdvancedSystems.Connector/AdvancedSystems.Connector.csproj index b6394a4..6920219 100644 --- a/AdvancedSystems.Connector/AdvancedSystems.Connector.csproj +++ b/AdvancedSystems.Connector/AdvancedSystems.Connector.csproj @@ -3,7 +3,7 @@ net8.0 0.0.0-alpha - TODO + Provides a database access layer to streamline database connections. AdvancedSystems.Connector AdvancedSystems.Connector Advanced Systems Connector Library diff --git a/AdvancedSystems.Connector/Common/DataSource.cs b/AdvancedSystems.Connector/Common/DataSource.cs index 49da794..f058636 100644 --- a/AdvancedSystems.Connector/Common/DataSource.cs +++ b/AdvancedSystems.Connector/Common/DataSource.cs @@ -1,18 +1,30 @@ -namespace AdvancedSystems.Connector.Common; - -public sealed class DataSource +using AdvancedSystems.Connector.Options; + +namespace AdvancedSystems.Connector.Common; + +/// +/// Represents a data source with a host and port. +/// +/// +/// The host of the data source. +/// +/// +/// The port of the data source. +/// +/// +public sealed record DataSource(string Host, int Port) { - public DataSource(string host, int port) - { - this.Host = host; - this.Port = port; - } - #region Properties - public string Host { get; set; } + /// + /// Gets or sets the host of the data source. + /// + public string Host { get; set; } = Host; - public int Port { get; set; } + /// + /// Gets or sets the port of the data source. + /// + public int Port { get; set; } = Port; #endregion @@ -33,4 +45,4 @@ public static implicit operator string(DataSource dataSource) } #endregion -} +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Common/DatabaseCommand.cs b/AdvancedSystems.Connector/Common/DatabaseCommand.cs deleted file mode 100644 index 4477e88..0000000 --- a/AdvancedSystems.Connector/Common/DatabaseCommand.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Diagnostics; -using System.Globalization; -using System.Text; - -using AdvancedSystems.Connector.Abstractions; -using AdvancedSystems.Connector.Converters; - -namespace AdvancedSystems.Connector.Common; - -[DebuggerDisplay("{CommandText}")] -public sealed class DatabaseCommand : IDatabaseCommand -{ - #region Properties - - [DisplayName("Command Text")] - public required string CommandText { get; set; } - - [DisplayName("Command Type")] - public required DatabaseCommandType CommandType { get; set; } - - [DisplayName("Parameters")] - public List Parameters { get; } = []; - - #endregion - - #region Methods - - public void AddParameter(IDatabaseParameter parameter) - { - this.Parameters.Add(parameter); - } - - public void AddParameter(string name, T value) - { - var parameter = new DatabaseParameter - { - ParameterName = name, - SqlDbType = typeof(T).Cast(), - Value = (object?)value ?? DBNull.Value - }; - - this.Parameters.Add(parameter); - } - - private static string? FormatValue(IDatabaseParameter parameter) - { - return parameter.SqlDbType switch - { - SqlDbType.VarChar or SqlDbType.Char => parameter.Value?.ToString(), - SqlDbType.Bit => ((bool?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.TinyInt => ((byte?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.SmallInt => ((short?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.Int => ((int?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.BigInt => ((long?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.Real => ((float?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.Float => ((double?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.Decimal => ((decimal?)parameter.Value)?.ToString(CultureInfo.InvariantCulture), - SqlDbType.DateTime => $"'{((DateTime?)parameter.Value)?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)}'", - _ => parameter.Value?.ToString(), - }; - - } - - public override string ToString() - { - if (this.Parameters.Count == 0) return this.CommandText; - - var commandBuilder = new StringBuilder(this.CommandText); - - foreach (var parameter in this.Parameters) - { - commandBuilder.Replace(parameter.ParameterName, DatabaseCommand.FormatValue(parameter)); - } - - return commandBuilder.ToString(); - } - - #endregion -} diff --git a/AdvancedSystems.Connector/Common/DatabaseParameter.cs b/AdvancedSystems.Connector/Common/DatabaseParameter.cs deleted file mode 100644 index d9e0301..0000000 --- a/AdvancedSystems.Connector/Common/DatabaseParameter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Data; - -using AdvancedSystems.Connector.Abstractions; - -namespace AdvancedSystems.Connector.Common; - -public sealed class DatabaseParameter : IDatabaseParameter -{ - #region Properties - - public required string ParameterName { get; set; } - - public required SqlDbType SqlDbType { get; set; } - - public required object? Value { get; set; } - - #endregion -} diff --git a/AdvancedSystems.Connector/Common/Sections.cs b/AdvancedSystems.Connector/Common/Sections.cs new file mode 100644 index 0000000..0e50aca --- /dev/null +++ b/AdvancedSystems.Connector/Common/Sections.cs @@ -0,0 +1,8 @@ +namespace AdvancedSystems.Connector.Common; + +public static class Sections +{ + public const string DATABASE = "Database"; + + public const string USERS = "Users"; +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Converters/MsSqlServerExtensions.cs b/AdvancedSystems.Connector/Converters/MsSqlServerExtensions.cs deleted file mode 100644 index fc286ca..0000000 --- a/AdvancedSystems.Connector/Converters/MsSqlServerExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Data; - -using AdvancedSystems.Connector.Abstractions; -using AdvancedSystems.Connector.Options; - -using Microsoft.Data.SqlClient; - -namespace AdvancedSystems.Connector.Converters; - -internal static class MsSqlServerExtensions -{ - #region Helpers - - internal static string CreateConnectionString(this MsSqlServerSettings settings) - { - var builder = new SqlConnectionStringBuilder - { - // Database Options - ApplicationName = settings.ApplicationName, - DataSource = settings.DataSource, - InitialCatalog = settings.InitialCatalog, - Password = settings.Password, - UserID = settings.UserID, - // Microsoft SQL Server Options - CommandTimeout = settings.CommandTimeout, - ConnectTimeout = settings.ConnectTimeout, - Encrypt = settings.Encrypt, - IntegratedSecurity = settings.IntegratedSecurity, - MaxPoolSize = settings.MaxPoolSize, - MinPoolSize = settings.MinPoolSize, - Pooling = settings.Pooling, - TrustServerCertificate = settings.TrustServerCertificate, - }; - - return builder.ToString(); - } - - #endregion - - #region Converters - - internal static CommandType Cast(this DatabaseCommandType commandType) - { - return commandType switch - { - DatabaseCommandType.Text => CommandType.Text, - DatabaseCommandType.StoredProcedure => CommandType.StoredProcedure, - _ => throw new NotImplementedException(Enum.GetName(commandType)), - }; - } - - internal static SqlDbType Cast(this Type type) - { - var typeCode = Type.GetTypeCode(Nullable.GetUnderlyingType(type) ?? type); - - return typeCode switch - { - TypeCode.String => SqlDbType.VarChar, - TypeCode.Char => SqlDbType.Char, - TypeCode.Boolean => SqlDbType.Bit, - TypeCode.Byte => SqlDbType.TinyInt, - TypeCode.SByte or TypeCode.Int16 => SqlDbType.SmallInt, - TypeCode.Int32 => SqlDbType.Int, - TypeCode.Int64 => SqlDbType.BigInt, - TypeCode.Single => SqlDbType.Real, - TypeCode.Double => SqlDbType.Float, - TypeCode.Decimal => SqlDbType.Decimal, - TypeCode.DateTime => SqlDbType.DateTime, - _ => throw new ArgumentException($"Failed to infer type from {typeCode}."), - }; - } - - #endregion -} diff --git a/AdvancedSystems.Connector/DependencyInjection/ServiceCollectionExtensions.cs b/AdvancedSystems.Connector/DependencyInjection/ServiceCollectionExtensions.cs index f328a45..d7de86b 100644 --- a/AdvancedSystems.Connector/DependencyInjection/ServiceCollectionExtensions.cs +++ b/AdvancedSystems.Connector/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using System; using AdvancedSystems.Connector.Abstractions; -using AdvancedSystems.Connector.Options; +using AdvancedSystems.Connector.Internals; using AdvancedSystems.Connector.Services; using Microsoft.Extensions.Configuration; @@ -12,24 +12,55 @@ namespace AdvancedSystems.Connector.DependencyInjection; public static class ServiceCollectionExtensions { + /// + /// Decrypts an encrypted password. + /// + /// + /// The encrypted password to be decrypted. + /// + /// + /// The decrypted plain-text password. + /// public delegate string DecryptPassword(string cipher); - #region DbConnectionService + #region AddDbConnectionService - private static IServiceCollection AddDbConnectionService(this IServiceCollection services) where T : class, IDatabaseConnectionService + private static IServiceCollection AddDbConnectionService(this IServiceCollection services) where T : class, IDbSettings, new() { - services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton(typeof(IDbConnectionService), typeof(DbConnectionService))); + services.TryAdd(ServiceDescriptor.Singleton()); return services; } - private static IServiceCollection AddDbConnectionService(this IServiceCollection services, Action setupAction, DecryptPassword? decryptPassword = null) where T : class, IDatabaseConnectionService where U : DatabaseOptions + /// + /// Adds the default implementation of to . + /// + /// + /// The type of option to use for the connection service. + /// + /// + /// The service collection containing the service. + /// + /// + /// A configuration section targeting . + /// + /// + /// Defines a function handler for optional password decryption. + /// + /// + /// The value of . + /// + public static IServiceCollection AddDbConnectionService(this IServiceCollection services, IConfigurationSection configuration, DecryptPassword? decryptPassword = null) where T : class, IDbSettings, new() { - services.AddOptions() - .Configure(setupAction) - .PostConfigure(options => + services.AddOptions() + .Bind(configuration) + .PostConfigure(options => { if (decryptPassword != null) { + ArgumentNullException.ThrowIfNull(options.Password); options.Password = decryptPassword(options.Password); } }); @@ -38,39 +69,40 @@ private static IServiceCollection AddDbConnectionService(this IServiceColl return services; } - private static IServiceCollection AddDbConnectionService(this IServiceCollection services, IConfiguration configuration, DecryptPassword? decryptPassword = null) where T : class, IDatabaseConnectionService where U : DatabaseOptions + /// + /// Adds the default implementation of to . + /// + /// + /// The type of option to use for the connection service. + /// + /// + /// The service collection containing the service. + /// + /// + /// An action used to configure . + /// + /// + /// Defines a function handler for optional password decryption. + /// + /// + /// The value of . + /// + public static IServiceCollection AddDbConnectionService(this IServiceCollection services, Action setupAction, DecryptPassword? decryptPassword = null) where T : class, IDbSettings, new() { - services.AddOptions() - .Bind(configuration.GetRequiredSection(Sections.DATABASE)) + services.AddOptions() + .Configure(setupAction) .PostConfigure(options => { if (decryptPassword != null) { + ArgumentNullException.ThrowIfNull(options.Password); options.Password = decryptPassword(options.Password); } - }) - .ValidateDataAnnotations() - .ValidateOnStart(); + }); services.AddDbConnectionService(); return services; } #endregion - - #region Microsoft SQL Server - - public static IServiceCollection AddMsSqlServerConnectionService(this IServiceCollection services, Action setupAction, DecryptPassword? decryptPassword = null) - { - services.AddDbConnectionService(setupAction, decryptPassword); - return services; - } - - public static IServiceCollection AddMsSqlServerConnectionService(this IServiceCollection services, IConfiguration configuration, DecryptPassword? decryptPassword = null) - { - services.AddDbConnectionService(configuration, decryptPassword); - return services; - } - - #endregion -} +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Extensions/DbCommandExtensions.cs b/AdvancedSystems.Connector/Extensions/DbCommandExtensions.cs new file mode 100644 index 0000000..b8116a6 --- /dev/null +++ b/AdvancedSystems.Connector/Extensions/DbCommandExtensions.cs @@ -0,0 +1,20 @@ +using System; +using System.Data.Common; + +namespace AdvancedSystems.Connector.Extensions; + +internal static class DbCommandExtensions +{ + internal static DbDataAdapter CreateDbDataAdapter(this DbCommand dbCommand) + { + DbConnection? dbConnection = dbCommand.Connection; + ArgumentNullException.ThrowIfNull(dbConnection, nameof(dbCommand)); + DbProviderFactory? factory = DbProviderFactories.GetFactory(dbConnection); + + DbDataAdapter? adapter = factory?.CreateDataAdapter(); + ArgumentNullException.ThrowIfNull(adapter, nameof(dbCommand)); + adapter.SelectCommand = dbCommand; + + return adapter; + } +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Extensions/OptionsExtensions.cs b/AdvancedSystems.Connector/Extensions/OptionsExtensions.cs new file mode 100644 index 0000000..23f5f76 --- /dev/null +++ b/AdvancedSystems.Connector/Extensions/OptionsExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.Data.Common; + +using AdvancedSystems.Connector.Options; + +using Microsoft.Data.SqlClient; + +namespace AdvancedSystems.Connector.Extensions; + +public static class OptionsExtensions +{ + private const string MASK = "******"; + + public static string CreateGenericConnectionString(this DbOptions options, bool maskPassword) + { + var builder = new DbConnectionStringBuilder(); + + if (options.ApplicationName is not null) + { + builder.Add("Application Name", options.ApplicationName); + } + + builder.Add("Data Source", options.DataSource ?? throw new ArgumentNullException(nameof(options))); + builder.Add("Initial Catalog", options.InitialCatalog ?? throw new ArgumentNullException(nameof(options))); + builder.Add("Password", options.Password ?? throw new ArgumentNullException(nameof(options))); + builder.Add("User ID", maskPassword ? MASK : options.UserID ?? throw new ArgumentNullException(nameof(options))); + + return builder.ConnectionString; + } + + public static string CreateMsSqlConnectionString(this MsSqlOptions options, bool maskPassword) + { + var builder = new SqlConnectionStringBuilder + { + ApplicationName = options.ApplicationName, + DataSource = options.DataSource, + InitialCatalog = options.InitialCatalog, + Password = maskPassword ? MASK : options.Password, + UserID = options.UserID, + CommandTimeout = options.CommandTimeout, + ConnectTimeout = options.ConnectTimeout, + Encrypt = options.Encrypt, + IntegratedSecurity = options.IntegratedSecurity, + MaxPoolSize = options.MaxPoolSize, + MinPoolSize = options.MinPoolSize, + Pooling = options.Pooling, + TrustServerCertificate = options.TrustServerCertificate, + }; + + return builder.ToString(); + } +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Internals/DbConnection.cs b/AdvancedSystems.Connector/Internals/DbConnection.cs new file mode 100644 index 0000000..62688de --- /dev/null +++ b/AdvancedSystems.Connector/Internals/DbConnection.cs @@ -0,0 +1,218 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Abstractions.Exceptions; + +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using DbConnectionInternal = System.Data.Common.DbConnection; + +namespace AdvancedSystems.Connector.Internals; + +public sealed class DbConnection : IDbConnection where T : class, IDbSettings, new() +{ + private readonly ILogger _logger; + private readonly IDbConnectionStringFactory _dbConnectionStringFactory; + private readonly IDbConnectionFactory _dbConnectionFactory; + private readonly IDbDataAdapterFactory _dbDataAdapterFactory; + + private bool _isDisposed = false; + private string? _connectionString; + private readonly DbConnectionInternal _dbConnection; + + public DbConnection(ILogger logger, IOptions options, IDbConnectionStringFactory dbConnectionStringFactory, IDbConnectionFactory dbConnectionFactory, IDbDataAdapterFactory dbDataAdapterFactory) + { + this._logger = logger; + this.Options = options.Value; + this._dbConnectionStringFactory = dbConnectionStringFactory; + this._dbConnectionFactory = dbConnectionFactory; + this._dbDataAdapterFactory = dbDataAdapterFactory; + + try + { + string connectionString = this._dbConnectionStringFactory.Create(this.Options.DbProvider, maskPassword: false); + this._dbConnection = this._dbConnectionFactory.Create(this.Options.DbProvider, connectionString); + } + catch (DbException exception) + { + throw new DbConnectionException(exception.Message, exception); + } + } + + public static DbConnection Create(T options, ILogger? logger = null) + { + throw new NotImplementedException(); + } + + public static ValueTask CreateAsync(T options, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + #region Properties + + public T Options { get; private set; } + + public string ConnectionString + { + get + { + if (this._connectionString is null) + { + this._connectionString = this._dbConnectionStringFactory.Create(this.Options.DbProvider, maskPassword: true); + } + + return this._connectionString; + } + } + + public ConnectionState ConnectionState { get; private set; } + + #endregion + + #region Methods + + public void Dispose(bool disposing) + { + this._isDisposed = this._dbConnection is null; + + if (this._isDisposed || !disposing) return; + + try + { + // https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.close#remarks + this._dbConnection?.Close(); + } + catch (SqlException exception) + { + this._logger?.LogError(exception.Message); + this.ConnectionState = ConnectionState.Broken; + } + finally + { + this._isDisposed = true; + this.ConnectionState = ConnectionState.Closed; + } + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + string IDbConnection.ToString() + { + return this.ToString(); + } + + /// + public override string ToString() + { + return this.ConnectionString; + } + + public DataSet ExecuteQuery(DbCommand dbCommand) + { + DataSet result = new(); + + this.ExecutionHandler((cmd) => + { + using DbDataAdapter adapter = this._dbDataAdapterFactory.Create(cmd); + adapter.Fill(result); + }, dbCommand); + + return result; + } + + public async ValueTask ExecuteQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default) + { + DataTable result = new(); + + await this.ExecutionHandlerAsync(async (cmd) => + { + using DbDataReader reader = await cmd.ExecuteReaderAsync(cancellationToken); + result.Load(reader); + }, dbCommand, cancellationToken); + + return result.DataSet; + } + + public int ExecuteNonQuery(DbCommand dbCommand) + { + int rowsAffected = -1; + + this.ExecutionHandler((cmd) => rowsAffected = cmd.ExecuteNonQuery(), dbCommand); + + return rowsAffected; + } + + public async ValueTask ExecuteNonQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default) + { + int rowsAffected = -1; + + await this.ExecutionHandlerAsync(async (cmd) => rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken), dbCommand, cancellationToken); + + return rowsAffected; + } + + #endregion + + #region Helpers + + private void OpenConnection() + { + this._logger.LogTrace("Opening connection to database"); + this._dbConnection.Open(); + this.ConnectionState = ConnectionState.Open; + } + + private async ValueTask OpenConnectionAsync(CancellationToken cancellationToken) + { + this._logger.LogTrace("Closing connection to database"); + await this._dbConnection.OpenAsync(cancellationToken); + this.ConnectionState = ConnectionState.Open; + } + + private void ExecutionHandler(Action action, DbCommand dbCommand) + { + try + { + this.OpenConnection(); + dbCommand.Connection = this._dbConnection; + this.ConnectionState = ConnectionState.Executing; + action(dbCommand); + this.ConnectionState = ConnectionState.Open; + } + catch (DbException exception) + { + this.ConnectionState = ConnectionState.Broken; + throw new DbConnectionException(exception.Message, exception); + } + } + + private async ValueTask ExecutionHandlerAsync(Func action, DbCommand dbCommand, CancellationToken cancellationToken) + { + try + { + await this.OpenConnectionAsync(cancellationToken); + this.ConnectionState = ConnectionState.Executing; + await action(dbCommand); + this.ConnectionState = ConnectionState.Open; + } + catch (DbException exception) + { + this.ConnectionState = ConnectionState.Broken; + throw new DbConnectionException(exception.Message, exception); + } + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Internals/DbConnectionFactory.cs b/AdvancedSystems.Connector/Internals/DbConnectionFactory.cs new file mode 100644 index 0000000..5ffc781 --- /dev/null +++ b/AdvancedSystems.Connector/Internals/DbConnectionFactory.cs @@ -0,0 +1,24 @@ +using System; +using System.Data.Common; + +using AdvancedSystems.Connector.Abstractions; + +using Microsoft.Data.SqlClient; + +namespace AdvancedSystems.Connector.Internals; + +public sealed class DbConnectionFactory : IDbConnectionFactory +{ + #region Methods + + public System.Data.Common.DbConnection Create(DbProvider dbProvider, string connectionString) + { + return dbProvider switch + { + DbProvider.MsSql => new SqlConnection(connectionString), + _ => throw new NotImplementedException($"Operation not supported for the '{dbProvider}' database provider."), + }; + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Internals/DbConnectionStringFactory.cs b/AdvancedSystems.Connector/Internals/DbConnectionStringFactory.cs new file mode 100644 index 0000000..9e1998d --- /dev/null +++ b/AdvancedSystems.Connector/Internals/DbConnectionStringFactory.cs @@ -0,0 +1,34 @@ +using System; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Extensions; +using AdvancedSystems.Connector.Options; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AdvancedSystems.Connector.Internals; + +public sealed class DbConnectionStringFactory : IDbConnectionStringFactory +{ + private readonly IServiceProvider _serviceProvider; + + public DbConnectionStringFactory(IServiceProvider serviceProvider) + { + this._serviceProvider = serviceProvider; + } + + #region Methods + + public string Create(DbProvider dbProvider, bool maskPassword = false) + { + return dbProvider switch + { + DbProvider.Generic => this._serviceProvider.GetRequiredService>().Value.CreateGenericConnectionString(maskPassword), + DbProvider.MsSql => this._serviceProvider.GetRequiredService>().Value.CreateMsSqlConnectionString(maskPassword), + _ => throw new NotImplementedException($"Operation not supported for the '{dbProvider}' database dbProvider."), + }; + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Internals/DbDataAdapterFactory.cs b/AdvancedSystems.Connector/Internals/DbDataAdapterFactory.cs new file mode 100644 index 0000000..a5e2baf --- /dev/null +++ b/AdvancedSystems.Connector/Internals/DbDataAdapterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Data.Common; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Extensions; + +using Microsoft.Data.SqlClient; + +using DbCommand = System.Data.Common.DbCommand; + +namespace AdvancedSystems.Connector.Internals; + +public sealed class DbDataAdapterFactory : IDbDataAdapterFactory +{ + #region Methods + + public DbDataAdapter Create(DbCommand dbCommand) + { + return dbCommand switch + { + SqlCommand sqlCommand => new SqlDataAdapter(sqlCommand), + DbCommand => dbCommand.CreateDbDataAdapter(), + _ => throw new NotImplementedException() + }; + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Options/DatabaseOptions.cs b/AdvancedSystems.Connector/Options/DatabaseOptions.cs deleted file mode 100644 index b5321e6..0000000 --- a/AdvancedSystems.Connector/Options/DatabaseOptions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -using Microsoft.Data.SqlClient; - -namespace AdvancedSystems.Connector.Options; - -public class DatabaseOptions -{ - /// - /// Gets or sets the name of the application associated with the connection string. - /// - [DisplayName("Application Name")] - public required string ApplicationName { get; set; } - - /// - /// Gets or sets the name or network address of the instance of SQL Server to connect to. - /// - [Required] - [DisplayName("Data Source")] - public required string DataSource { get; set; } - - /// - /// Gets or sets the name of the database associated with the connection. - /// - [Required] - [DisplayName("Initial Catalog")] - public required string InitialCatalog { get; set; } - - /// - /// Gets or sets the password for the SQL Server account. - /// - [Required] - [DisplayName("Password")] - public required string Password { get; set; } - - /// - /// Gets or sets the user ID to be used when connecting to SQL Server. - /// - [Required] - [DisplayName("User ID")] - public required string UserID { get; set; } -} diff --git a/AdvancedSystems.Connector/Options/DbOptions.cs b/AdvancedSystems.Connector/Options/DbOptions.cs new file mode 100644 index 0000000..24b53bd --- /dev/null +++ b/AdvancedSystems.Connector/Options/DbOptions.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +using AdvancedSystems.Connector.Abstractions; + +namespace AdvancedSystems.Connector.Options; + +/// +/// +/// +public record DbOptions : IDbSettings +{ + public DbOptions(DbProvider dbProvider = DbProvider.Generic) + { + this.DbProvider = dbProvider; + } + + #region Properties + + [DisplayName("Application Name")] + public string? ApplicationName { get; set; } + + [Required] + [DisplayName("Data Source")] + public string? DataSource { get; set; } + + [Required] + [DisplayName("Initial Catalog")] + public string? InitialCatalog { get; set; } + + [Required] + [DisplayName("Password")] + public string? Password { get; set; } + + [Required] + [DisplayName("DB DbProvider")] + [JsonConverter(typeof(DbProvider))] + public DbProvider DbProvider { get; private set; } + + [Required] + [DisplayName("User ID")] + public string? UserID { get; set; } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Options/MsSqlServerSettings.cs b/AdvancedSystems.Connector/Options/MsSqlOptions.cs similarity index 51% rename from AdvancedSystems.Connector/Options/MsSqlServerSettings.cs rename to AdvancedSystems.Connector/Options/MsSqlOptions.cs index c5c8596..e80d1bd 100644 --- a/AdvancedSystems.Connector/Options/MsSqlServerSettings.cs +++ b/AdvancedSystems.Connector/Options/MsSqlOptions.cs @@ -1,65 +1,117 @@ using System.ComponentModel; +using AdvancedSystems.Connector.Abstractions; + using Microsoft.Data.SqlClient; namespace AdvancedSystems.Connector.Options; -public sealed class MsSqlServerSettings : DatabaseOptions +public sealed record MsSqlOptions() : DbOptions(DbProvider.MsSql) { /// /// Gets or sets the default wait time (in seconds) before terminating the attempt to execute - /// a command and generating an error. The default is 30 seconds. + /// a command and generating an error. /// + /// + /// + /// Defaults to 30. + /// + /// [DisplayName("Command Timeout")] - public int CommandTimeout { get; set; } + public int CommandTimeout { get; set; } = 30; /// /// Gets or sets the length of time (in seconds) to wait for a connection to the server /// before terminating the attempt and generating an error. /// + /// + /// + /// Defaults to 15. + /// + /// [DisplayName("Connect Timeout")] - public int ConnectTimeout { get; set; } + public int ConnectTimeout { get; set; } = 15; /// /// Gets or sets a value that indicates whether /// TLS encryption is required for all data sent between the client and server. /// + /// + /// + /// Defaults to . + /// + /// [DisplayName("Encrypt")] - public SqlConnectionEncryptOption? Encrypt { get; set; } + public SqlConnectionEncryptOption? Encrypt { get; set; } = SqlConnectionEncryptOption.Optional; /// /// Gets or sets a Boolean value that indicates whether User ID and Password are /// specified in the connection (when false) or whether the current Windows account /// credentials are used for authentication (when true). /// + /// + /// + /// Defaults to . + /// + /// [DisplayName("Integrated Security")] - public bool IntegratedSecurity { get; set; } + public bool IntegratedSecurity { get; set; } = false; /// /// Gets or sets the maximum number of connections allowed in the connection pool for this specific /// connection string. /// + /// + /// + /// Defaults to 100. + /// + /// [DisplayName("Max Pool Size")] - public int MaxPoolSize { get; set; } + public int MaxPoolSize { get; set; } = 100; /// /// Gets or sets the minimum number of connections allowed in the connection pool for this specific /// connection string. /// + /// + /// + /// Defaults to 0. + /// + /// [DisplayName("Min Pool Size")] - public int MinPoolSize { get; set; } + public int MinPoolSize { get; set; } = 0; /// /// Gets or sets a Boolean value that indicates whether the connection will be pooled or explicitly /// opened every time that the connection is requested. /// + /// + /// + /// Defaults to . + /// + /// [DisplayName("Pooling")] - public bool Pooling { get; set; } + public bool Pooling { get; set; } = true; /// /// Gets or sets a value that indicates whether the channel will be encrypted while bypassing /// walking the certificate chain to validate trust. /// + /// + /// + /// Defaults to . + /// + /// [DisplayName("Trust Server Certificate")] - public bool TrustServerCertificate { get; set; } -} + public bool TrustServerCertificate { get; set; } = false; + + /// + /// + /// + /// + /// + /// Configured as . + /// + /// + public new DbProvider DbProvider { get; private set; } +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Options/Sections.cs b/AdvancedSystems.Connector/Options/Sections.cs deleted file mode 100644 index 22cc1f8..0000000 --- a/AdvancedSystems.Connector/Options/Sections.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace AdvancedSystems.Connector.Options; - -public readonly record struct Sections -{ - public const string DATABASE = "Database"; -} diff --git a/AdvancedSystems.Connector/Services/DatabaseConnectionFactory.cs b/AdvancedSystems.Connector/Services/DatabaseConnectionFactory.cs deleted file mode 100644 index cc0d8b8..0000000 --- a/AdvancedSystems.Connector/Services/DatabaseConnectionFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -using AdvancedSystems.Connector.Abstractions; - -using Microsoft.Extensions.DependencyInjection; - -namespace AdvancedSystems.Connector.Services; - -public sealed class DatabaseConnectionFactory : IDatabaseConnectionFactory -{ - private readonly IServiceProvider _serviceProvider; - - public DatabaseConnectionFactory(IServiceProvider serviceProvider) - { - this._serviceProvider = serviceProvider; - } - - public IDatabaseConnectionService Create(Provider provider) - { - return provider switch - { - Provider.MsSql => this._serviceProvider.GetRequiredService(), - _ => throw new NotSupportedException(Enum.GetName(provider)), - }; - } -} diff --git a/AdvancedSystems.Connector/Services/DbConnectionService.cs b/AdvancedSystems.Connector/Services/DbConnectionService.cs new file mode 100644 index 0000000..1715328 --- /dev/null +++ b/AdvancedSystems.Connector/Services/DbConnectionService.cs @@ -0,0 +1,56 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedSystems.Connector.Abstractions; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AdvancedSystems.Connector.Services; + +/// +public sealed class DbConnectionService : IDbConnectionService where T : class, IDbSettings, new() +{ + private readonly ILogger> _logger; + + public DbConnectionService(ILogger> logger, IOptions options) + { + this._logger = logger; + this.Options = options.Value; + } + + #region Properties + + public ConnectionState ConnectionState { get; private set; } + + public T Options { get; private set; } + + #endregion + + #region Methods + + public DataSet ExecuteQuery(DbCommand dbCommand) + { + throw new NotImplementedException(); + } + + public ValueTask ExecuteQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public int ExecuteNonQuery(DbCommand dbCommand) + { + throw new NotImplementedException(); + } + + public ValueTask ExecuteNonQueryAsync(DbCommand dbCommand, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Services/DbConnectionServiceFactory.cs b/AdvancedSystems.Connector/Services/DbConnectionServiceFactory.cs new file mode 100644 index 0000000..54fc4b1 --- /dev/null +++ b/AdvancedSystems.Connector/Services/DbConnectionServiceFactory.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using AdvancedSystems.Connector.Abstractions; +using AdvancedSystems.Connector.Abstractions.Exceptions; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AdvancedSystems.Connector.Services; + +/// +public sealed class DbConnectionServiceFactory : IDbConnectionServiceFactory where T : class, IDbFactorySettings, new() where U : class, IDbSettings, new() +{ + private readonly ILogger> _logger; + private readonly List _dbConnectionOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly List> _dbConnectionServices; + + public DbConnectionServiceFactory(ILogger> logger, ILoggerFactory loggerFactory, IOptions factoryOptions) + { + this._logger = logger; + this._loggerFactory = loggerFactory; + this.Options = factoryOptions.Value; + this._dbConnectionOptions = this.Options.Options; + this._dbConnectionServices = [.. this.Create()]; + } + + #region Properties + + public T Options { get; private set; } + + #endregion + + #region Methods + + public IDbConnectionService GetConnection(string dbUser) + { + IDbConnectionService? dbConnectionService = this._dbConnectionServices.FirstOrDefault(x => string.Equals(dbUser, x.Options.UserID, StringComparison.Ordinal)); + return dbConnectionService ?? throw new DbConnectionException($"Failed to retrieve connection from factory ({dbUser})."); + } + + public IDbConnectionService GetConnection(Predicate predicate) + { + IDbConnectionService? dbConnectionService = this._dbConnectionServices.FirstOrDefault(x => predicate(x.Options)); + return dbConnectionService ?? throw new DbConnectionException("Failed to retrieve connection from factory (predicate)."); + } + + #endregion + + #region Helpers + + private IEnumerable> Create() + { + ILogger> logger = this._loggerFactory.CreateLogger>(); + + foreach (U dbOption in this._dbConnectionOptions) + { + U dbSettings = dbOption; + IOptions connectionOptions = Microsoft.Extensions.Options.Options.Create(dbSettings); + + yield return new DbConnectionService(logger, connectionOptions); + } + } + + #endregion +} \ No newline at end of file diff --git a/AdvancedSystems.Connector/Services/MsSqlServerConnectionService.cs b/AdvancedSystems.Connector/Services/MsSqlServerConnectionService.cs deleted file mode 100644 index 11b8d93..0000000 --- a/AdvancedSystems.Connector/Services/MsSqlServerConnectionService.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Data; -using System.Data.Common; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedSystems.Connector.Abstractions; -using AdvancedSystems.Connector.Abstractions.Exceptions; -using AdvancedSystems.Connector.Converters; -using AdvancedSystems.Connector.Options; - -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace AdvancedSystems.Connector.Services; - -public sealed class MsSqlServerConnectionService : IDatabaseConnectionService -{ - private readonly ILogger _logger; - private readonly MsSqlServerSettings _settings; - - public MsSqlServerConnectionService(ILogger logger, IOptions options) - { - this._logger = logger; - this._settings = options.Value; - - this.ConnectionString = this._settings.CreateConnectionString(); - } - - #region Properties - - public string ConnectionString { get; private set; } - - public ConnectionState ConnectionState { get; private set; } - - #endregion - - #region Helpers - - private void ConnectionStateHandler(object sender, StateChangeEventArgs e) - { - this.ConnectionState = e.CurrentState; - } - - private void InfoMessageHandler(object sender, SqlInfoMessageEventArgs e) - { - if (e.Errors.Count == 0) return; - - byte warningThreshold = 10; - byte errorThreshold = 20; - SqlError lastError = e.Errors[^1]; - - if (lastError.Class <= warningThreshold) - { - this._logger.LogWarning("{Server} issued the following warning: {Message}.", lastError.Server, lastError.Message); - } - else if (lastError.Class > warningThreshold && lastError.Class <= errorThreshold) - { - this._logger.LogError("{Server} raised an error on line {Line}: {Message} (State={State}).", lastError.Server, lastError.LineNumber, lastError.Message, lastError.State); - } - else - { - // A severity over 20 causes the connection to close - string reason = "Connection was closed"; - this._logger.LogCritical("{Reason} ({Message}).", reason, lastError.Message); - throw new DbConnectionException($"{reason} ({lastError.Message})."); - } - } - - private void InvokeExceptionHandler(Action action, IDatabaseCommand databaseCommand) - { - try - { - action(databaseCommand); - this._logger.LogTrace("Executed command '{Query}'.", databaseCommand); - } - catch (SqlException exception) - { - string reason = $"Database failed to execute command {databaseCommand}"; - this._logger.LogError("{Reason} ({Message}).", reason, exception.Message); - throw new DbCommandExecutionException($"{reason} ({exception.Message}).", exception); - } - catch (DbException exception) - { - string reason = $"Communication to database failed during the execution of {databaseCommand}"; - this._logger.LogError("{Reason} ({Message}).", reason, exception.Message); - throw new DbConnectionException($"{reason} ({exception.Message}).", exception); - } - } - - private async ValueTask InvokeExceptionHandlerAsync(Func action, IDatabaseCommand databaseCommand, CancellationToken cancellationToken = default) - { - try - { - await action(databaseCommand, cancellationToken); - this._logger.LogTrace("Executed command '{Query}'.", databaseCommand); - } - catch (SqlException exception) - { - string reason = $"Database failed to execute command {databaseCommand}"; - this._logger.LogError("{Reason} ({Message}).", reason, exception.Message); - throw new DbCommandExecutionException($"{reason} ({exception.Message}).", exception); - } - catch (DbException exception) - { - string reason = $"Communication with the database failed or was interrupted during the execution off {databaseCommand}"; - this._logger.LogError("{Reason} ({Message}).", reason, exception.Message); - throw new DbConnectionException($"{reason} ({exception.Message}).", exception); - } - } - - #endregion - - #region Methods - - public DataSet ExecuteQuery(IDatabaseCommand databaseCommand) - { - DataSet result = new(); - - this.InvokeExceptionHandler((databaseCommand) => - { - using var connection = new SqlConnection(this.ConnectionString); - connection.StateChange += ConnectionStateHandler; - connection.InfoMessage += InfoMessageHandler; - connection.Open(); - - using var sqlCommand = connection.CreateCommand(); - sqlCommand.CommandText = databaseCommand.CommandText; - sqlCommand.CommandType = databaseCommand.CommandType.Cast(); - - foreach (var parameter in databaseCommand.Parameters) - { - sqlCommand.Parameters.AddWithValue(parameter.ParameterName, parameter.Value); - } - - using var adapter = new SqlDataAdapter(sqlCommand); - adapter.Fill(result); - }, databaseCommand); - - return result; - } - - public async ValueTask ExecuteQueryAsync(IDatabaseCommand databaseCommand, CancellationToken cancellationToken = default) - { - DataTable result = new(); - - await this.InvokeExceptionHandlerAsync(async (databaseCommand, cancellationToken) => - { - using var connection = new SqlConnection(this.ConnectionString); - connection.StateChange += ConnectionStateHandler; - connection.InfoMessage += InfoMessageHandler; - await connection.OpenAsync(cancellationToken); - - using var sqlCommand = connection.CreateCommand(); - sqlCommand.CommandText = databaseCommand.CommandText; - sqlCommand.CommandType = databaseCommand.CommandType.Cast(); - - foreach (var parameter in databaseCommand.Parameters) - { - sqlCommand.Parameters.AddWithValue(parameter.ParameterName, parameter.Value); - } - - using var reader = await sqlCommand.ExecuteReaderAsync(cancellationToken); - result.Load(reader); - }, databaseCommand, cancellationToken); - - return result.DataSet; - } - - public int ExecuteNonQuery(IDatabaseCommand databaseCommand) - { - int rowsAffected = default; - - this.InvokeExceptionHandler((databaseCommand) => - { - using var connection = new SqlConnection(this.ConnectionString); - connection.StateChange += ConnectionStateHandler; - connection.InfoMessage += InfoMessageHandler; - connection.Open(); - - using var sqlCommand = connection.CreateCommand(); - sqlCommand.CommandText = databaseCommand.CommandText; - sqlCommand.CommandType = databaseCommand.CommandType.Cast(); - - foreach (var parameter in databaseCommand.Parameters) - { - sqlCommand.Parameters.AddWithValue(parameter.ParameterName, parameter.Value); - } - - rowsAffected = sqlCommand.ExecuteNonQuery(); - }, databaseCommand); - - return rowsAffected; - } - - public async ValueTask ExecuteNonQueryAsync(IDatabaseCommand databaseCommand, CancellationToken cancellationToken = default) - { - int rowsAffected = default; - - await this.InvokeExceptionHandlerAsync(async (databaseCommand, cancellationToken) => - { - using var connection = new SqlConnection(this.ConnectionString); - connection.StateChange += ConnectionStateHandler; - connection.InfoMessage += InfoMessageHandler; - await connection.OpenAsync(cancellationToken); - - using var sqlCommand = connection.CreateCommand(); - sqlCommand.CommandText = databaseCommand.CommandText; - sqlCommand.CommandType = databaseCommand.CommandType.Cast(); - - foreach (var parameter in databaseCommand.Parameters) - { - sqlCommand.Parameters.AddWithValue(parameter.ParameterName, parameter.Value); - } - - rowsAffected = await sqlCommand.ExecuteNonQueryAsync(cancellationToken); - }, databaseCommand, cancellationToken); - - return rowsAffected; - } - - #endregion -} diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..5c64254 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,54 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "../AdvancedSystems.Connector", + "files": [ + "**/*.csproj" + ] + } + ], + "dest": "api", + "disableGitFeatures": false, + "disableDefaultFilter": false + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.{md,yml}" + ], + "exclude": [ + "_site/**" + ] + } + ], + "resource": [ + { + "files": [ + "images/**" + ] + } + ], + "output": "_site", + "template": [ + "default", + "modern" + ], + "globalMetadata": { + "_appName": "AdvancedSystems.Connector", + "_appTitle": "AdvancedSystems.Connnector", + "_appFaviconPath": "images/favicon.svg", + "_appLogoPath": "images/adv-logo-brand.svg", + "_appFooter": "Copyright © Advanced Systems 2024", + "_disableContribution": false, + "_gitContribute": { + "repo": "https://github.com/Advanced-Systems/connector" + }, + "_enableSearch": true, + "pdf": false + } + } +} diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md new file mode 100644 index 0000000..9a593cf --- /dev/null +++ b/docs/docs/changelog.md @@ -0,0 +1,3 @@ +# Changelog + +TODO diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md new file mode 100644 index 0000000..d5eca4a --- /dev/null +++ b/docs/docs/getting-started.md @@ -0,0 +1,3 @@ +# Getting Started + +TODO diff --git a/docs/docs/introduction.md b/docs/docs/introduction.md new file mode 100644 index 0000000..cdd72da --- /dev/null +++ b/docs/docs/introduction.md @@ -0,0 +1,3 @@ +# Introduction + +TODO diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml new file mode 100644 index 0000000..47aa192 --- /dev/null +++ b/docs/docs/toc.yml @@ -0,0 +1,6 @@ +- name: Introduction + href: introduction.md +- name: Getting Started + href: getting-started.md +- name: Changelog + href: changelog.md diff --git a/docs/images/adv-logo-brand.svg b/docs/images/adv-logo-brand.svg new file mode 100644 index 0000000..1d4d04f --- /dev/null +++ b/docs/images/adv-logo-brand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/images/favicon.svg b/docs/images/favicon.svg new file mode 100644 index 0000000..efbed0e --- /dev/null +++ b/docs/images/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e66bdef --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +--- +_layout: landing +--- + +# Advanced Systems Connector + +Provides a database access layer to streamline database connections. diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..061acc6 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,4 @@ +- name: Docs + href: docs/ +- name: API + href: api/ \ No newline at end of file diff --git a/readme.md b/readme.md index c372877..dd78181 100644 --- a/readme.md +++ b/readme.md @@ -6,10 +6,51 @@

Advanced Systems Connector

+[![Unit Tests](https://github.com/Advanced-Systems/connector/actions/workflows/dotnet-tests.yml/badge.svg)](https://github.com/Advanced-Systems/connector/actions/workflows/dotnet-tests.yml) +[![CodeQL](https://github.com/Advanced-Systems/connector/actions/workflows/codeql.yml/badge.svg)](https://github.com/Advanced-Systems/connector/actions/workflows/codeql.yml) +[![Docs](https://github.com/Advanced-Systems/connector/actions/workflows/docs.yml/badge.svg)](https://github.com/Advanced-Systems/connector/actions/workflows/docs.yml) + ## About -TODO +Provides a database access layer to streamline database connections. This package can be installed +from the public [NuGet Gallery](https://www.nuget.org/packages/AdvancedSystems.Connector): + +```powershell +dotnet add package AdvancedSystems.Connector +``` + +The changelog for this package are available [here](https://advanced-systems.github.io/connector/docs/changelog.html). + +Package consumers can also use the symbols published to nuget.org symbol server by adding +to their symbol sources in Visual Studio, which allows stepping into package code in the Visual Studio debugger. See +[Specify symbol (.pdb) and source files in the Visual Studio debugger](https://learn.microsoft.com/en-us/visualstudio/debugger/specify-symbol-dot-pdb-and-source-files-in-the-visual-studio-debugger) +for details on that process. + +Additionally, this project also supports [source link technology](https://learn.microsoft.com/en-us/dotnet/standard/library-guidance/sourcelink) +for debugging .NET assemblies. ## Developer Notes -TODO +Run test suite: + +```powershell +dotnet test .\AdvancedSystems.Connector.Tests\ --nologo +``` + +In addition to unit testing, this project also uses stryker for mutation testing, which is setup to be installed with + +```powershell +dotnet tool restore --configfile nuget.config +``` + +Run stryker locally: + +```powershell +dotnet stryker +``` + +Build and serve documentation locally (`http://localhost:8080`): + +```powershell +docfx .\docs\docfx.json --serve +```