diff --git a/ClickView.GoodStuff.sln b/ClickView.GoodStuff.sln index 11561d1..83ecf4f 100644 --- a/ClickView.GoodStuff.sln +++ b/ClickView.GoodStuff.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.28917.181 @@ -103,6 +103,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Vault", "Vault", "{AFB5C6D0 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClickView.GoodStuff.Configuration.Vault", "src\Configuration\Vault\src\ClickView.GoodStuff.Configuration.Vault.csproj", "{0CE2213A-4C95-4692-96BC-B16079C601BE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Snowflake", "Snowflake", "{FB39F90B-78CE-416D-99DF-ED74B3D5483E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClickView.GoodStuff.Repositories.Snowflake", "src\Repositories\Snowflake\src\ClickView.GoodStuff.Repositories.Snowflake.csproj", "{AAC65E72-7744-42D6-94DB-D2041B8DC39C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClickView.GoodStuff.Repositories.Snowflake.Tests", "src\Repositories\Snowflake\test\ClickView.GoodStuff.Repositories.Snowflake.Tests.csproj", "{6C099EEC-28F1-4F65-B6AF-9410AC821B7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -213,6 +219,14 @@ Global {0CE2213A-4C95-4692-96BC-B16079C601BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {0CE2213A-4C95-4692-96BC-B16079C601BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {0CE2213A-4C95-4692-96BC-B16079C601BE}.Release|Any CPU.Build.0 = Release|Any CPU + {AAC65E72-7744-42D6-94DB-D2041B8DC39C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAC65E72-7744-42D6-94DB-D2041B8DC39C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAC65E72-7744-42D6-94DB-D2041B8DC39C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAC65E72-7744-42D6-94DB-D2041B8DC39C}.Release|Any CPU.Build.0 = Release|Any CPU + {6C099EEC-28F1-4F65-B6AF-9410AC821B7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C099EEC-28F1-4F65-B6AF-9410AC821B7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C099EEC-28F1-4F65-B6AF-9410AC821B7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C099EEC-28F1-4F65-B6AF-9410AC821B7C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -255,6 +269,9 @@ Global {51DC2CBC-4C90-4533-919F-7053F7E21FD8} = {41CD17D6-2B4C-4E27-9ABB-D21A6276BE7A} {AFB5C6D0-7ABE-4809-90C8-6A3EB2CE1A14} = {28B6F3FE-DB59-4FB9-A0F1-264E17CFA5C9} {0CE2213A-4C95-4692-96BC-B16079C601BE} = {AFB5C6D0-7ABE-4809-90C8-6A3EB2CE1A14} + {FB39F90B-78CE-416D-99DF-ED74B3D5483E} = {17A5F05F-B3EB-4011-A58C-BB1BB7995692} + {AAC65E72-7744-42D6-94DB-D2041B8DC39C} = {FB39F90B-78CE-416D-99DF-ED74B3D5483E} + {6C099EEC-28F1-4F65-B6AF-9410AC821B7C} = {FB39F90B-78CE-416D-99DF-ED74B3D5483E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F935033F-FAFD-48D4-843C-C7C8A9AE6562} diff --git a/src/Repositories/Snowflake/src/BaseSnowflakeRepository.cs b/src/Repositories/Snowflake/src/BaseSnowflakeRepository.cs new file mode 100644 index 0000000..c3f30f5 --- /dev/null +++ b/src/Repositories/Snowflake/src/BaseSnowflakeRepository.cs @@ -0,0 +1,141 @@ +namespace ClickView.GoodStuff.Repositories.Snowflake; + +using System.Threading; +using Abstractions; +using Dapper; +using global::Snowflake.Data.Client; + +public abstract class BaseSnowflakeRepository(ISnowflakeConnectionFactory connectionFactory) + : BaseRepository(connectionFactory) +{ + /// + /// Executes a write command + /// + /// + /// + /// + /// + protected Task ExecuteAsync(string sql, object? param = null, CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.ExecuteAsync(cd), + write: true, + sql, + param, + cancellationToken); + } + + /// + /// Executes a write command which selects a single value + /// + /// + /// + /// + /// + /// + protected Task ExecuteScalarAsync(string sql, object? param = null, + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.ExecuteScalarAsync(cd), + write: true, + sql, + param, + cancellationToken); + } + + /// + /// Executes a single value query + /// + /// + /// + /// + /// + /// + protected Task QueryScalarValueAsync(string sql, object? param = null, + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.ExecuteScalarAsync(cd), + write: false, + sql, + param, + cancellationToken); + } + + /// + /// Executes a single row query + /// + /// + /// + /// + /// + /// + protected Task QueryFirstOrDefaultAsync(string sql, object? param = null, + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.QueryFirstOrDefaultAsync(cd), + write: false, + sql, + param, + cancellationToken); + } + + /// + /// Executes a single row query and throws an exception if more than one record is found + /// + /// + /// + /// + /// + /// + protected Task QuerySingleOrDefaultAsync(string sql, object? param = null, + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.QuerySingleOrDefaultAsync(cd), + write: false, + sql, + param, + cancellationToken); + } + + /// + /// Executes a multiple row query + /// + /// + /// + /// + /// + /// + protected Task> QueryAsync(string sql, object? param = null, + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.QueryAsync(cd), + write: false, + sql, + param, + cancellationToken); + } + + protected Task> QueryAsync(string sql, + Func map, object? param = null, string splitOn = "Id", + CancellationToken cancellationToken = default) + { + return WrapAsync((con, cd) => con.QueryAsync(cd, map, splitOn: splitOn), + write: false, + sql, + param, + cancellationToken); + } + + private async Task WrapAsync(Func> func, + bool write, string sql, object? param, CancellationToken cancellationToken) + { +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await using var conn = write ? GetWriteConnection() : GetReadConnection(); +#else + using var conn = write ? GetWriteConnection() : GetReadConnection(); +#endif + + var command = new CommandDefinition(sql, param, cancellationToken: cancellationToken); + + return await func(conn, command); + } +} diff --git a/src/Repositories/Snowflake/src/ClickView.GoodStuff.Repositories.Snowflake.csproj b/src/Repositories/Snowflake/src/ClickView.GoodStuff.Repositories.Snowflake.csproj new file mode 100644 index 0000000..3704ab6 --- /dev/null +++ b/src/Repositories/Snowflake/src/ClickView.GoodStuff.Repositories.Snowflake.csproj @@ -0,0 +1,17 @@ + + + + $(FullTargetFrameworks) + true + + + + + + + + + + + + diff --git a/src/Repositories/Snowflake/src/ISnowflakeConnectionFactory.cs b/src/Repositories/Snowflake/src/ISnowflakeConnectionFactory.cs new file mode 100644 index 0000000..69340ee --- /dev/null +++ b/src/Repositories/Snowflake/src/ISnowflakeConnectionFactory.cs @@ -0,0 +1,6 @@ +namespace ClickView.GoodStuff.Repositories.Snowflake; + +using Abstractions.Factories; +using global::Snowflake.Data.Client; + +public interface ISnowflakeConnectionFactory : IConnectionFactory; diff --git a/src/Repositories/Snowflake/src/SnowflakeConnectionFactory.cs b/src/Repositories/Snowflake/src/SnowflakeConnectionFactory.cs new file mode 100644 index 0000000..9dd79f7 --- /dev/null +++ b/src/Repositories/Snowflake/src/SnowflakeConnectionFactory.cs @@ -0,0 +1,42 @@ +namespace ClickView.GoodStuff.Repositories.Snowflake; + +using Abstractions.Factories; +using global::Snowflake.Data.Client; + +public class SnowflakeConnectionFactory : SnowflakeConnectionFactory +{ + public SnowflakeConnectionFactory(ConnectionFactoryOptions options) : base(options) + { + } +} + +public class SnowflakeConnectionFactory + : ConnectionFactory, ISnowflakeConnectionFactory + where TOptions : SnowflakeConnectionOptions +{ + public SnowflakeConnectionFactory(ConnectionFactoryOptions options) : base(options) + { + } + + public override SnowflakeDbConnection GetReadConnection() + { + if (string.IsNullOrEmpty(ReadConnectionString)) + throw new InvalidOperationException("Read is not allowed. No read connection options defined"); + + var connection = new SnowflakeDbConnection { ConnectionString = ReadConnectionString }; + connection.Open(); + + return connection; + } + + public override SnowflakeDbConnection GetWriteConnection() + { + if (string.IsNullOrEmpty(WriteConnectionString)) + throw new InvalidOperationException("Write is not allowed. No write connection options defined"); + + var connection = new SnowflakeDbConnection { ConnectionString = WriteConnectionString }; + connection.Open(); + + return connection; + } +} diff --git a/src/Repositories/Snowflake/src/SnowflakeConnectionOptions.cs b/src/Repositories/Snowflake/src/SnowflakeConnectionOptions.cs new file mode 100644 index 0000000..1b7a0ae --- /dev/null +++ b/src/Repositories/Snowflake/src/SnowflakeConnectionOptions.cs @@ -0,0 +1,61 @@ +namespace ClickView.GoodStuff.Repositories.Snowflake; + +using Abstractions; + +public class SnowflakeConnectionOptions : RepositoryConnectionOptions +{ + /// + /// The full account name which might include additional segments that identify the region and + /// cloud platform where your account is hosted + /// + public string? Account + { + set => SetParameter("account", value); + get => GetParameter("account"); + } + + /// + /// The name of the warehouse to use + /// + public string? Warehouse + { + set => SetParameter("warehouse", value); + get => GetParameter("warehouse"); + } + + /// + /// The database to use + /// + public string? Database + { + set => SetParameter("db", value); + get => GetParameter("db"); + } + + /// + /// The schema to use + /// + public string? Schema + { + set => SetParameter("schema", value); + get => GetParameter("schema"); + } + + /// + /// The Snowflake user to use + /// + public string? User + { + set => SetParameter("user", value); + get => GetParameter("user"); + } + + /// + /// The password for the Snowflake user + /// + public string? Password + { + set => SetParameter("password", value); + get => GetParameter("password"); + } +} diff --git a/src/Repositories/Snowflake/test/ClickView.GoodStuff.Repositories.Snowflake.Tests.csproj b/src/Repositories/Snowflake/test/ClickView.GoodStuff.Repositories.Snowflake.Tests.csproj new file mode 100644 index 0000000..727f178 --- /dev/null +++ b/src/Repositories/Snowflake/test/ClickView.GoodStuff.Repositories.Snowflake.Tests.csproj @@ -0,0 +1,22 @@ + + + + $(TestTargetFrameworks) + false + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/Repositories/Snowflake/test/SnowflakeConnectionOptionsTests.cs b/src/Repositories/Snowflake/test/SnowflakeConnectionOptionsTests.cs new file mode 100644 index 0000000..36f819e --- /dev/null +++ b/src/Repositories/Snowflake/test/SnowflakeConnectionOptionsTests.cs @@ -0,0 +1,103 @@ +namespace ClickView.GoodStuff.Repositories.Snowflake.Tests; + +using Xunit; + +public class SnowflakeConnectionOptionsTests +{ + [Fact] + public void GetConnectionString_Default_Empty() + { + var options = new SnowflakeConnectionOptions(); + + var connString = options.GetConnectionString(); + + Assert.Equal(string.Empty, connString); + } + + [Fact] + public void GetConnectionString_AllOptionsSet_Valid() + { + var options = new SnowflakeConnectionOptions + { + Host = "host", + Account = "acc", + Warehouse = "wh", + Database = "db", + Schema = "sch", + User = "user", + Password = "pass" + }; + + var connString = options.GetConnectionString(); + + Assert.Equal( + "host=host;" + + "account=acc;" + + "warehouse=wh;" + + "db=db;" + + "schema=sch;" + + "user=user;" + + "password=pass;", + connString); + } + + [Fact] + public void PropertiesSet_AllOptionsSet_Valid() + { + var options = new SnowflakeConnectionOptions + { + Host = "host", + Account = "acc", + Warehouse = "wh", + Database = "db", + Schema = "sch", + User = "user", + Password = "pass" + }; + + Assert.Equal("host", options.Host); + Assert.Equal("acc", options.Account); + Assert.Equal("wh", options.Warehouse); + Assert.Equal("db", options.Database); + Assert.Equal("sch", options.Schema); + Assert.Equal("user", options.User); + Assert.Equal("pass", options.Password); + } + + [Fact] + public void PropertiesSet_Default_Empty() + { + var options = new SnowflakeConnectionOptions(); + + Assert.Null(options.Host); + Assert.Null(options.Account); + Assert.Null(options.Warehouse); + Assert.Null(options.Database); + Assert.Null(options.Schema); + Assert.Null(options.User); + Assert.Null(options.Password); + } + + [Fact] + public void PropertiesSet_Null_Empty() + { + var options = new SnowflakeConnectionOptions + { + Host = null, + Account = null, + Warehouse = null, + Database = null, + Schema = null, + User = null, + Password = null + }; + + Assert.Null(options.Host); + Assert.Null(options.Account); + Assert.Null(options.Warehouse); + Assert.Null(options.Database); + Assert.Null(options.Schema); + Assert.Null(options.User); + Assert.Null(options.Password); + } +}