diff --git a/.github/workflows/nuget.yaml b/.github/workflows/nuget.yaml new file mode 100644 index 0000000..a44ed3d --- /dev/null +++ b/.github/workflows/nuget.yaml @@ -0,0 +1,32 @@ +name: nuget +on: + push: + paths: + - src/** + - test/** + - Okkema.sln + - .github/workflows/nuget.yaml +concurrency: nuget +jobs: + nuget: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.x + source-url: https://nuget.pkg.github.com/okkema/index.json + env: + NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Build solution + run: dotnet build + - name: Test solution + run: dotnet test + - name: Package release + run: dotnet pack --configuration Release + - name: Publish release + run: dotnet nuget push src/**/bin/Release/*.nupkg --skip-duplicate + + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..1746e32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +obj diff --git a/Okkema.sln b/Okkema.sln new file mode 100644 index 0000000..d6c8572 --- /dev/null +++ b/Okkema.sln @@ -0,0 +1,78 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{63284BC1-0B65-4978-96E0-8FC776A25137}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Cache", "src\Okkema.Cache\Okkema.Cache.csproj", "{7032D94B-6467-4EB7-A44D-5B0970B3A0AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Messages", "src\Okkema.Messages\Okkema.Messages.csproj", "{9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Queue", "src\Okkema.Queue\Okkema.Queue.csproj", "{6F026E63-5067-4212-A246-14CC936F2747}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.SQL", "src\Okkema.SQL\Okkema.SQL.csproj", "{8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{4F3AF5ED-CBA8-4D32-B754-60B246F885FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Cache.Test", "test\Okkema.Cache.Test\Okkema.Cache.Test.csproj", "{D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Queue.Test", "test\Okkema.Queue.Test\Okkema.Queue.Test.csproj", "{B68169EE-B6C2-4F30-9CBA-95958480630C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.SQL.Test", "test\Okkema.SQL.Test\Okkema.SQL.Test.csproj", "{147200B7-C2D3-4745-9223-38D8ABB2C4C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Okkema.Test", "test\Okkema.Test\Okkema.Test.csproj", "{3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7032D94B-6467-4EB7-A44D-5B0970B3A0AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7032D94B-6467-4EB7-A44D-5B0970B3A0AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7032D94B-6467-4EB7-A44D-5B0970B3A0AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7032D94B-6467-4EB7-A44D-5B0970B3A0AE}.Release|Any CPU.Build.0 = Release|Any CPU + {9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50}.Release|Any CPU.Build.0 = Release|Any CPU + {6F026E63-5067-4212-A246-14CC936F2747}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F026E63-5067-4212-A246-14CC936F2747}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F026E63-5067-4212-A246-14CC936F2747}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F026E63-5067-4212-A246-14CC936F2747}.Release|Any CPU.Build.0 = Release|Any CPU + {8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC}.Release|Any CPU.Build.0 = Release|Any CPU + {D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1}.Release|Any CPU.Build.0 = Release|Any CPU + {B68169EE-B6C2-4F30-9CBA-95958480630C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B68169EE-B6C2-4F30-9CBA-95958480630C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B68169EE-B6C2-4F30-9CBA-95958480630C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B68169EE-B6C2-4F30-9CBA-95958480630C}.Release|Any CPU.Build.0 = Release|Any CPU + {147200B7-C2D3-4745-9223-38D8ABB2C4C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {147200B7-C2D3-4745-9223-38D8ABB2C4C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {147200B7-C2D3-4745-9223-38D8ABB2C4C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {147200B7-C2D3-4745-9223-38D8ABB2C4C8}.Release|Any CPU.Build.0 = Release|Any CPU + {3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7032D94B-6467-4EB7-A44D-5B0970B3A0AE} = {63284BC1-0B65-4978-96E0-8FC776A25137} + {9B1317F2-E6D2-4AD0-930A-C6F8E31BFE50} = {63284BC1-0B65-4978-96E0-8FC776A25137} + {6F026E63-5067-4212-A246-14CC936F2747} = {63284BC1-0B65-4978-96E0-8FC776A25137} + {8A76B4D7-4ACE-4A63-84B8-3FF3E0BD42EC} = {63284BC1-0B65-4978-96E0-8FC776A25137} + {D1FE27D3-D7AC-4FC0-AFEC-18E52ACB5EA1} = {4F3AF5ED-CBA8-4D32-B754-60B246F885FD} + {B68169EE-B6C2-4F30-9CBA-95958480630C} = {4F3AF5ED-CBA8-4D32-B754-60B246F885FD} + {147200B7-C2D3-4745-9223-38D8ABB2C4C8} = {4F3AF5ED-CBA8-4D32-B754-60B246F885FD} + {3A584C1A-D18A-44DB-B6EC-FDB66CDB7D04} = {4F3AF5ED-CBA8-4D32-B754-60B246F885FD} + EndGlobalSection +EndGlobal diff --git a/src/Okkema.Cache/CacheService.cs b/src/Okkema.Cache/CacheService.cs new file mode 100755 index 0000000..46a0c50 --- /dev/null +++ b/src/Okkema.Cache/CacheService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; +namespace Okkema.Cache; +public sealed class CacheService : ICacheService where T : class, new() +{ + private readonly IDistributedCache _cache; + private readonly CacheSignal _signal; + public CacheService(IDistributedCache cache, + CacheSignal signal) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _signal = signal ?? throw new ArgumentNullException(nameof(signal)); + + } + public async Task GetAsync(string key, CancellationToken token = default) + { + try + { + await _signal.WaitAsync(); + var json = await _cache.GetStringAsync(key, token); + if (string.IsNullOrWhiteSpace(json)) return default; + return JsonSerializer.Deserialize(json); + } + finally + { + _signal.Release(); + } + } + public async Task SetAsync(string key, T value, CancellationToken token = default) + { + try + { + await _signal.WaitAsync(); + var json = JsonSerializer.Serialize(value); + await _cache.SetStringAsync(key, json, token); + } + finally + { + _signal.Release(); + } + } +} diff --git a/src/Okkema.Cache/CacheSignal.cs b/src/Okkema.Cache/CacheSignal.cs new file mode 100755 index 0000000..1973edb --- /dev/null +++ b/src/Okkema.Cache/CacheSignal.cs @@ -0,0 +1,7 @@ +namespace Okkema.Cache; +public sealed class CacheSignal +{ + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + public Task WaitAsync() => _semaphore.WaitAsync(); + public void Release() => _semaphore.Release(); +} diff --git a/src/Okkema.Cache/Extensions/ServiceCollectionExtensions.cs b/src/Okkema.Cache/Extensions/ServiceCollectionExtensions.cs new file mode 100755 index 0000000..4c29e72 --- /dev/null +++ b/src/Okkema.Cache/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +namespace Okkema.Cache.Extensions; +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMemoryCache(this IServiceCollection services, IConfiguration configuration) + { + services.AddDistributedMemoryCache(); + return services; + } + public static IServiceCollection AddCacheService(this IServiceCollection services, IConfiguration configuration) where T : class, new() + { + services.AddSingleton>(); + services.AddScoped, CacheService>(); + return services; + } +} \ No newline at end of file diff --git a/src/Okkema.Cache/ICacheService.cs b/src/Okkema.Cache/ICacheService.cs new file mode 100755 index 0000000..3ab8236 --- /dev/null +++ b/src/Okkema.Cache/ICacheService.cs @@ -0,0 +1,6 @@ +namespace Okkema.Cache; +public interface ICacheService +{ + public Task GetAsync(string key, CancellationToken token); + public Task SetAsync(string key, T value, CancellationToken token); +} diff --git a/src/Okkema.Cache/Okkema.Cache.csproj b/src/Okkema.Cache/Okkema.Cache.csproj new file mode 100755 index 0000000..dae663a --- /dev/null +++ b/src/Okkema.Cache/Okkema.Cache.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + Okkema.Cache + 0.1.0 + Benjamin Okkema + Okkema Labs + Okkema Labs Cache Utilities + https://github.com/cptchloroplast/movies + enable + enable + + + + + + + + + diff --git a/src/Okkema.Messages/Extensions/ServiceCollectionExtensions.cs b/src/Okkema.Messages/Extensions/ServiceCollectionExtensions.cs new file mode 100755 index 0000000..ab8c69a --- /dev/null +++ b/src/Okkema.Messages/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Okkema.Queue.Extensions; +using Okkema.Messages; +using Okkema.Messages.Handlers; +namespace Okkema.Messages.Extensions; +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMessageHandler(this IServiceCollection services) + where TMessage : MessageBase + where THandler : MessageHandlerBase + { + services.AddHostedService(); + services.AddSingleton, THandler>(); + services.AddChannelConsumer(); + return services; + } +} \ No newline at end of file diff --git a/src/Okkema.Messages/Handlers/IMessageHandler.cs b/src/Okkema.Messages/Handlers/IMessageHandler.cs new file mode 100755 index 0000000..ce590f4 --- /dev/null +++ b/src/Okkema.Messages/Handlers/IMessageHandler.cs @@ -0,0 +1,5 @@ +namespace Okkema.Messages.Handlers; +public interface IMessageHandler where T : MessageBase +{ + public Task HandleAsync(T message, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Okkema.Messages/Handlers/MessageHandlerBase.cs b/src/Okkema.Messages/Handlers/MessageHandlerBase.cs new file mode 100755 index 0000000..fb571c2 --- /dev/null +++ b/src/Okkema.Messages/Handlers/MessageHandlerBase.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Okkema.Queue.Consumers; +namespace Okkema.Messages.Handlers; +public abstract class MessageHandlerBase : BackgroundService, IMessageHandler + where T : MessageBase +{ + protected readonly ILogger> _logger; + private readonly IConsumer _consumer; + public MessageHandlerBase( + ILogger> logger, + IConsumer consumer) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); + } + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await _consumer.ReadAsync(HandleAsync, cancellationToken); + } + } + public abstract Task HandleAsync(T message, CancellationToken cancellationToken = default); + +} \ No newline at end of file diff --git a/src/Okkema.Messages/MessageBase.cs b/src/Okkema.Messages/MessageBase.cs new file mode 100755 index 0000000..2c6cd65 --- /dev/null +++ b/src/Okkema.Messages/MessageBase.cs @@ -0,0 +1,6 @@ +namespace Okkema.Messages; +public abstract record MessageBase +{ + public Guid SystemKey { get; init; } = Guid.NewGuid(); + public DateTime SystemCreatedDate { get; init; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/Okkema.Messages/Okkema.Messages.csproj b/src/Okkema.Messages/Okkema.Messages.csproj new file mode 100644 index 0000000..eef3f79 --- /dev/null +++ b/src/Okkema.Messages/Okkema.Messages.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + Okkema.Messages + 0.1.1 + Benjamin Okkema + Okkema Labs + Okkema Labs Message Utilities + https://github.com/okkema/dotnet + enable + enable + + + + + + + + + + + + + diff --git a/src/Okkema.Queue/Consumers/ChannelConsmer.cs b/src/Okkema.Queue/Consumers/ChannelConsmer.cs new file mode 100755 index 0000000..c129327 --- /dev/null +++ b/src/Okkema.Queue/Consumers/ChannelConsmer.cs @@ -0,0 +1,22 @@ +using System.Threading.Channels; +namespace Okkema.Queue.Consumers; +public sealed class ChannelConsumer : IConsumer +{ + private readonly Channel _channel; + public ChannelConsumer( + Channel channel) + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + public async Task ReadAsync(Func callback, CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested + && await _channel.Reader.WaitToReadAsync()) + { + if (_channel.Reader.TryRead(out var value)) + { + await callback(value, cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/src/Okkema.Queue/Consumers/IConsumer.cs b/src/Okkema.Queue/Consumers/IConsumer.cs new file mode 100755 index 0000000..47d2300 --- /dev/null +++ b/src/Okkema.Queue/Consumers/IConsumer.cs @@ -0,0 +1,5 @@ +namespace Okkema.Queue.Consumers; +public interface IConsumer +{ + public Task ReadAsync(Func callback, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Okkema.Queue/Extensions/ServiceCollectionExtensions.cs b/src/Okkema.Queue/Extensions/ServiceCollectionExtensions.cs new file mode 100755 index 0000000..1174791 --- /dev/null +++ b/src/Okkema.Queue/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Okkema.Queue.Consumers; +using Okkema.Queue.Producers; +using System.Threading.Channels; +namespace Okkema.Queue.Extensions; +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddChannelConsumer(this IServiceCollection services) + where T : class + { + services.AddSingleton, ChannelConsumer>(); + services.TryAddSingleton(Channel.CreateUnbounded()); + return services; + } + public static IServiceCollection AddChannelProducer(this IServiceCollection services) + where T : class + { + services.AddSingleton, ChannelProducer>(); + services.TryAddSingleton(Channel.CreateUnbounded()); + return services; + } +} \ No newline at end of file diff --git a/src/Okkema.Queue/Okkema.Queue.csproj b/src/Okkema.Queue/Okkema.Queue.csproj new file mode 100755 index 0000000..846e669 --- /dev/null +++ b/src/Okkema.Queue/Okkema.Queue.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + Okkema.Queue + 0.1.0 + Benjamin Okkema + Okkema Labs + Okkema Labs Queue Utilities + https://github.com/okkema/dotnet + enable + enable + + + + + + + + + diff --git a/src/Okkema.Queue/Producers/ChannelProducer.cs b/src/Okkema.Queue/Producers/ChannelProducer.cs new file mode 100755 index 0000000..ebdefda --- /dev/null +++ b/src/Okkema.Queue/Producers/ChannelProducer.cs @@ -0,0 +1,15 @@ +using System.Threading.Channels; +namespace Okkema.Queue.Producers; +public sealed class ChannelProducer : IProducer +{ + private readonly Channel _channel; + public ChannelProducer( + Channel channel) + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + public async Task WriteAsync(T value) + { + await _channel.Writer.WriteAsync(value); + } +} \ No newline at end of file diff --git a/src/Okkema.Queue/Producers/IProducer.cs b/src/Okkema.Queue/Producers/IProducer.cs new file mode 100755 index 0000000..ff35a78 --- /dev/null +++ b/src/Okkema.Queue/Producers/IProducer.cs @@ -0,0 +1,5 @@ +namespace Okkema.Queue.Producers; +public interface IProducer +{ + public Task WriteAsync(T value); +} \ No newline at end of file diff --git a/src/Okkema.SQL/Entities/EntityBase.cs b/src/Okkema.SQL/Entities/EntityBase.cs new file mode 100755 index 0000000..21c723c --- /dev/null +++ b/src/Okkema.SQL/Entities/EntityBase.cs @@ -0,0 +1,7 @@ +namespace Okkema.SQL.Entities; +public abstract record EntityBase +{ + public Guid SystemKey { get; init; } = Guid.NewGuid(); + public DateTime SystemCreatedDate { get; init; } = DateTime.UtcNow; + public DateTime SystemModifiedDate { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/Okkema.SQL/Extensions/DbConnectionExtensions.cs b/src/Okkema.SQL/Extensions/DbConnectionExtensions.cs new file mode 100644 index 0000000..7cc52d0 --- /dev/null +++ b/src/Okkema.SQL/Extensions/DbConnectionExtensions.cs @@ -0,0 +1,74 @@ +using System.Data; +namespace Okkema.SQL.Extensions; +public static class DbConnectionExtensions +{ + /// + /// Execute command in database + /// + /// Database connection + /// SQL statement + /// SQL parameters + /// Number of rows affected + public static int ExecuteCommand(this IDbConnection connection, string sql, object parameters) + { + var command = connection.CreateCommand(sql, parameters); + return command.ExecuteNonQuery(); + } + /// + /// Execute query in database + /// + /// Entity type + /// Database connection + /// SQL statement + /// SQL parameters + /// Query result + public static T? ExecuteQuery(this IDbConnection connection, string sql, object parameters) + where T : new() + { + return connection.ExecuteQuery, T>(sql, parameters).FirstOrDefault(); + } + public static T ExecuteQuery(this IDbConnection connection, string sql, object parameters) + where T : ICollection, new() + where U : new() + { + var command = connection.CreateCommand(sql, parameters); + using var reader = command.ExecuteReader(); + T? result = new(); + while (reader.Read()) + { + U? record = new(); + for (int index = 0, total = reader.FieldCount; index < total; index++) + { + var name = reader.GetName(index); + var property = typeof(T).GetProperty(name); + if (property is null) break; + var value = reader.GetValue(index); + if (value is not null) break; + var propertyType = property.PropertyType; + value = propertyType switch + { + Type _ when propertyType == typeof(int) => Convert.ToInt32(value), + Type _ when propertyType == typeof(DateTime) => DateTime.Parse(value!.ToString()!), + Type _ when propertyType == typeof(Guid) => Guid.Parse(value!.ToString()!), + _ => value, + }; + property.SetValue(record, value); + } + result.Add(record); + } + return result; + } + public static IDbCommand CreateCommand(this IDbConnection connection, string sql, object parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var property in parameters.GetType().GetProperties()) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = $"@{property.Name}"; + parameter.Value = property.GetValue(parameters); + command.Parameters.Add(parameter); + } + return command; + } +} \ No newline at end of file diff --git a/src/Okkema.SQL/Extensions/ServiceCollectionExtensions.cs b/src/Okkema.SQL/Extensions/ServiceCollectionExtensions.cs new file mode 100755 index 0000000..25a9b49 --- /dev/null +++ b/src/Okkema.SQL/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Okkema.SQL.Factories; +using Okkema.SQL.Options; +using FluentMigrator.Runner; +using System.Reflection; +namespace Okkema.SQL.Extensions; +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSQLite(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(nameof(DbConnectionOptions))) + .ValidateDataAnnotations(); + services.AddSingleton(); + return services; + } + public static IServiceCollection AddSQLiteMigrationRunner(this IServiceCollection services, IConfiguration configuration, Assembly[] assemblies) + { + var options = new DbConnectionOptions(); + configuration.Bind(nameof(DbConnectionOptions), options); + services.AddFluentMigratorCore() + .ConfigureRunner(x => x + .AddSQLite() + .WithGlobalConnectionString(options.ConnectionString) + .WithMigrationsIn(assemblies)) + .AddLogging(x => x.AddFluentMigratorConsole()); + return services; + } +} \ No newline at end of file diff --git a/src/Okkema.SQL/Factories/IDbConnectionFactory.cs b/src/Okkema.SQL/Factories/IDbConnectionFactory.cs new file mode 100755 index 0000000..5477f3b --- /dev/null +++ b/src/Okkema.SQL/Factories/IDbConnectionFactory.cs @@ -0,0 +1,6 @@ +using System.Data; +namespace Okkema.SQL.Factories; +public interface IDbConnectionFactory +{ + public IDbConnection CreateConnection(string connectionString); +} \ No newline at end of file diff --git a/src/Okkema.SQL/Factories/SQLiteConnectionFactory.cs b/src/Okkema.SQL/Factories/SQLiteConnectionFactory.cs new file mode 100755 index 0000000..41be493 --- /dev/null +++ b/src/Okkema.SQL/Factories/SQLiteConnectionFactory.cs @@ -0,0 +1,8 @@ +using Microsoft.Data.Sqlite; +using System.Data; +namespace Okkema.SQL.Factories; +public class SQLiteConnectionFactory : IDbConnectionFactory +{ + public IDbConnection CreateConnection(string connectionString) => + new SqliteConnection(connectionString); +} \ No newline at end of file diff --git a/src/Okkema.SQL/Okkema.SQL.csproj b/src/Okkema.SQL/Okkema.SQL.csproj new file mode 100755 index 0000000..3cc89d9 --- /dev/null +++ b/src/Okkema.SQL/Okkema.SQL.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + Okkema.SQL + 0.1.0 + Benjamin Okkema + Okkema Labs + Okkema Labs SQL Utilities + https://github.com/okkema/dotnet + README.md + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Okkema.SQL/Options/DbConnectionOptions.cs b/src/Okkema.SQL/Options/DbConnectionOptions.cs new file mode 100755 index 0000000..f8aa813 --- /dev/null +++ b/src/Okkema.SQL/Options/DbConnectionOptions.cs @@ -0,0 +1,7 @@ +using System.ComponentModel.DataAnnotations; +namespace Okkema.SQL.Options; +public class DbConnectionOptions +{ + [Required] + public string ConnectionString { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Okkema.SQL/README.md b/src/Okkema.SQL/README.md new file mode 100644 index 0000000..38d912f --- /dev/null +++ b/src/Okkema.SQL/README.md @@ -0,0 +1 @@ +# Okkema.SQL \ No newline at end of file diff --git a/src/Okkema.SQL/Repositories/IRepository.cs b/src/Okkema.SQL/Repositories/IRepository.cs new file mode 100755 index 0000000..7d34871 --- /dev/null +++ b/src/Okkema.SQL/Repositories/IRepository.cs @@ -0,0 +1,34 @@ +using Okkema.SQL.Entities; +namespace Okkema.SQL.Repositories; +public interface IRepository where T : EntityBase +{ + /// + /// Create a new entity in database + /// + /// Entity to create + /// Number of rows affected + public int Create(T entity); + /// + /// Read an entity from the database, returning null if it does not exist + /// + /// Entity system key + /// Entity + public T? Read(Guid key); + /// + /// Update an entity in the database + /// + /// Entity to update + /// Number of rows affected + public int Update(T entity); + /// + /// Delete an entity from the database + /// + /// Entity system key + /// Number of rows affected + public int Delete(Guid key); + /// + /// List entities from the database + /// + /// Entities + // public ICollection List(); +} \ No newline at end of file diff --git a/src/Okkema.SQL/Repositories/RepositoryBase.cs b/src/Okkema.SQL/Repositories/RepositoryBase.cs new file mode 100755 index 0000000..4c0cc8a --- /dev/null +++ b/src/Okkema.SQL/Repositories/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Options; +using System.Data; +using Okkema.SQL.Options; +using Okkema.SQL.Factories; +using Okkema.SQL.Entities; +using Microsoft.Extensions.Logging; +namespace Okkema.SQL.Repositories; +public abstract class RepositoryBase : IRepository + where TEntity : EntityBase +{ + protected readonly ILogger> _logger; + private readonly IDbConnectionFactory _factory; + private readonly IOptionsMonitor _options; + public RepositoryBase( + ILogger> logger, + IDbConnectionFactory factory, + IOptionsMonitor options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + protected TResult UseConnection(Func callback) + { + using var connection = _factory.CreateConnection(_options.CurrentValue.ConnectionString); + connection.Open(); + return callback(connection); + } + public abstract int Create(TEntity entity); + public abstract TEntity? Read(Guid key); + public abstract int Update(TEntity entity); + public abstract int Delete(Guid key); +} \ No newline at end of file diff --git a/test/Okkema.Cache.Test/CacheServiceTest.cs b/test/Okkema.Cache.Test/CacheServiceTest.cs new file mode 100755 index 0000000..7ea19aa --- /dev/null +++ b/test/Okkema.Cache.Test/CacheServiceTest.cs @@ -0,0 +1,35 @@ +using Xunit; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Okkema.Test; +using FluentAssertions; +namespace Okkema.Cache.Test; +public class CacheServiceTest +{ + private readonly IDistributedCache _cache; + private readonly CacheSignal _signal; + private readonly CacheService _service; + public CacheServiceTest() + { + var options = Options.Create(new MemoryDistributedCacheOptions()); + _cache = new MemoryDistributedCache(options); + _signal = new CacheSignal(); + _service = new CacheService(_cache, _signal); + } + [Theory] + [AutoMockData] + public async Task GetsDefaultWhenNoHit(string key) + { + var result = await _service.GetAsync(key); + result.Should().BeNull(); + } + [Theory] + [AutoMockData] + public async Task GetsValueThatHasBeenSet(string key, MockData value) + { + await _service.SetAsync(key, value); + var result = await _service.GetAsync(key); + result.Should().BeEquivalentTo(value); + } +} diff --git a/test/Okkema.Cache.Test/Okkema.Cache.Test.csproj b/test/Okkema.Cache.Test/Okkema.Cache.Test.csproj new file mode 100755 index 0000000..a62d7bb --- /dev/null +++ b/test/Okkema.Cache.Test/Okkema.Cache.Test.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Okkema.Queue.Test/ChannelQueueTest.cs b/test/Okkema.Queue.Test/ChannelQueueTest.cs new file mode 100755 index 0000000..3ef6a7a --- /dev/null +++ b/test/Okkema.Queue.Test/ChannelQueueTest.cs @@ -0,0 +1,29 @@ +using Xunit; +using Okkema.Test; +using System.Threading.Channels; +using Okkema.Queue.Consumers; +using Okkema.Queue.Producers; +using Moq; +namespace Okkema.Cache.Test; +public class ChannelQueueTest +{ + private readonly Channel _channel; + private readonly IProducer _producer; + private readonly IConsumer _consumer; + public ChannelQueueTest() + { + _channel = Channel.CreateUnbounded(); + _producer = new ChannelProducer(_channel); + _consumer = new ChannelConsumer(_channel); + } + [Theory] + [AutoMockData] + public async Task ProducesAndConsumesMessage(MockData value) + { + var callback = Mock.Of>(); + _ = Task.Run(() => _consumer.ReadAsync(callback)); + await _producer.WriteAsync(value); + await Task.Delay(1000); + Mock.Get(callback).Verify(x => x(value, It.IsAny()), Times.Once); + } +} diff --git a/test/Okkema.Queue.Test/Okkema.Queue.Test.csproj b/test/Okkema.Queue.Test/Okkema.Queue.Test.csproj new file mode 100755 index 0000000..50a8cc9 --- /dev/null +++ b/test/Okkema.Queue.Test/Okkema.Queue.Test.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Okkema.SQL.Test/Okkema.SQL.Test.csproj b/test/Okkema.SQL.Test/Okkema.SQL.Test.csproj new file mode 100755 index 0000000..34d2ead --- /dev/null +++ b/test/Okkema.SQL.Test/Okkema.SQL.Test.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/test/Okkema.SQL.Test/Repositories/AddTestEntityTable.cs b/test/Okkema.SQL.Test/Repositories/AddTestEntityTable.cs new file mode 100644 index 0000000..7f563db --- /dev/null +++ b/test/Okkema.SQL.Test/Repositories/AddTestEntityTable.cs @@ -0,0 +1,22 @@ +using FluentMigrator; +namespace Okkema.SQL.Test.Repositories; +[Migration(20231230)] +public class AddTestEntityTable : Migration +{ + public override void Up() + { + Create.Table("TestEntity") + .WithColumn("Integer").AsInt32() + .WithColumn("Long").AsInt64() + .WithColumn("Float").AsFloat() + .WithColumn("String").AsString() + .WithColumn("DateTime").AsDateTime() + .WithColumn("SystemKey").AsGuid().PrimaryKey().NotNullable().Indexed() + .WithColumn("SystemCreatedDate").AsString().NotNullable() + .WithColumn("SystemModifiedDate").AsString().NotNullable(); + } + public override void Down() + { + Delete.Table("TestEntity"); + } +} \ No newline at end of file diff --git a/test/Okkema.SQL.Test/Repositories/RepositoryBaseTest.cs b/test/Okkema.SQL.Test/Repositories/RepositoryBaseTest.cs new file mode 100755 index 0000000..e410b0a --- /dev/null +++ b/test/Okkema.SQL.Test/Repositories/RepositoryBaseTest.cs @@ -0,0 +1,73 @@ +using Okkema.SQL.Repositories; +using Okkema.SQL.Entities; +using Okkema.SQL.Options; +using Okkema.SQL.Factories; +using Microsoft.Extensions.Options; +using Moq; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Okkema.SQL.Extensions; +using FluentMigrator.Runner; +using System.Data; +using System.Reflection; +using Okkema.Test; +using Xunit; +namespace Okkema.SQL.Test.Repositories; +public abstract class RepositoryBaseTest : IDisposable + where T : EntityBase +{ + protected abstract IRepository Repository { get; } + protected readonly IDbConnectionFactory _factory; + protected readonly IOptionsMonitor _options; + private readonly IDbConnection _hold; + private readonly IMigrationRunner _runner; + public RepositoryBaseTest(Assembly[] assemblies) + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + var serviceProvider = new ServiceCollection() + .AddSQLite(configuration) + .AddSQLiteMigrationRunner(configuration, assemblies) + .BuildServiceProvider(); + + using var scope = serviceProvider.CreateScope(); + var options = new DbConnectionOptions(); + configuration.Bind(nameof(DbConnectionOptions), options); + _options = Mock.Of>(); + Mock.Get(_options).Setup(x => x.CurrentValue) + .Returns(options); + _factory = scope.ServiceProvider.GetRequiredService(); + _hold = _factory.CreateConnection(options.ConnectionString); + _hold.Open(); + _runner = scope.ServiceProvider.GetRequiredService(); + _runner.MigrateUp(); + } + public void Dispose() + { + _hold.Close(); + _hold.Dispose(); + } + + [Theory] + [AutoMockData] + public void CRUD(T entity) + { + // Empty + var empty = Repository.Read(entity.SystemKey); + empty.Should().BeNull(); + // Create + Repository.Create(entity); + var created = Repository.Read(entity.SystemKey); + created.Should().NotBe(entity); + // Update + Repository.Update(entity); + var updated = Repository.Read(entity.SystemKey); + updated.Should().NotBe(entity); + // Delete + Repository.Delete(entity.SystemKey); + var deleted = Repository.Read(entity.SystemKey); + deleted.Should().BeNull(); + } +} \ No newline at end of file diff --git a/test/Okkema.SQL.Test/Repositories/TestEntity.cs b/test/Okkema.SQL.Test/Repositories/TestEntity.cs new file mode 100644 index 0000000..09d9511 --- /dev/null +++ b/test/Okkema.SQL.Test/Repositories/TestEntity.cs @@ -0,0 +1,10 @@ +using Okkema.SQL.Entities; +namespace Okkema.SQL.Test.Repositories; +public record TestEntity : EntityBase +{ + public int Integer { get; set; } + public long Long { get; set; } + public float Float { get; set; } + public string String { get; set; } = string.Empty; + public DateTime DateTime { get; set; } +} diff --git a/test/Okkema.SQL.Test/Repositories/TestEntityRepository.cs b/test/Okkema.SQL.Test/Repositories/TestEntityRepository.cs new file mode 100644 index 0000000..ff6bb2f --- /dev/null +++ b/test/Okkema.SQL.Test/Repositories/TestEntityRepository.cs @@ -0,0 +1,81 @@ +using System.Data; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Okkema.SQL.Extensions; +using Okkema.SQL.Factories; +using Okkema.SQL.Options; +using Okkema.SQL.Repositories; +namespace Okkema.SQL.Test.Repositories; +public class TestEntityRepository : RepositoryBase +{ + public TestEntityRepository( + ILogger logger, + IDbConnectionFactory factory, + IOptionsMonitor options + ) : base(logger, factory, options) { } + private const string CREATE = @" + INSERT INTO TestEntity ( + Integer, + Long, + Float, + DateTime, + String, + SystemKey, + SystemCreatedDate, + SystemModifiedDate + ) + VALUES ( + @Integer, + @Long, + @Float, + @DateTime, + @String, + @SystemKey, + @SystemCreatedDate, + @SystemModifiedDate + )"; + public override int Create(TestEntity entity) => + UseConnection((IDbConnection connection) => + connection.ExecuteCommand(CREATE, entity)); + + private const string DELETE = @" + DELETE FROM TestEntity + WHERE SystemKey = @SystemKey"; + public override int Delete(Guid SystemKey) => + UseConnection((IDbConnection connection) => + connection.ExecuteCommand(DELETE, new { SystemKey })); + + private const string READ = @" + SELECT + Integer, + Long, + Float, + String, + DateTime, + SystemKey, + SystemCreatedDate, + SystemModifiedDate + FROM TestEntity + WHERE SystemKey = @SystemKey"; + public override TestEntity? Read(Guid SystemKey) => + UseConnection((IDbConnection connection) => + connection.ExecuteQuery(READ, new { SystemKey })); + + private const string UPDATE = @" + UPDATE TestEntity + SET + Integer = @Integer, + Long = @Long, + Float = @Float, + DateTime = @DateTime, + String = @String, + SystemModifiedDate = @SystemModifiedDate + WHERE + SystemKey = @SystemKey"; + public override int Update(TestEntity entity) => + UseConnection((IDbConnection connection) => + { + entity.SystemModifiedDate = DateTime.UtcNow; + return connection.ExecuteCommand(UPDATE, entity); + }); +} \ No newline at end of file diff --git a/test/Okkema.SQL.Test/Repositories/TestEntityRepositoryTest.cs b/test/Okkema.SQL.Test/Repositories/TestEntityRepositoryTest.cs new file mode 100644 index 0000000..c6d592e --- /dev/null +++ b/test/Okkema.SQL.Test/Repositories/TestEntityRepositoryTest.cs @@ -0,0 +1,16 @@ +using Okkema.SQL.Repositories; +using Microsoft.Extensions.Logging; +using Moq; +using System.Reflection; +namespace Okkema.SQL.Test.Repositories; +public class TestEntityRepositoryTest : RepositoryBaseTest +{ + private readonly TestEntityRepository _repo; + private readonly ILogger _logger; + protected override IRepository Repository { get => _repo; } + public TestEntityRepositoryTest() : base(new Assembly[] { typeof(AddTestEntityTable).Assembly }) + { + _logger = Mock.Of>(); + _repo = new TestEntityRepository(_logger, _factory, _options); + } +} \ No newline at end of file diff --git a/test/Okkema.SQL.Test/appsettings.json b/test/Okkema.SQL.Test/appsettings.json new file mode 100755 index 0000000..96f6803 --- /dev/null +++ b/test/Okkema.SQL.Test/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "None" + } + }, + "DbConnectionOptions": { + "ConnectionString": "Data Source=Test;Mode=Memory;Cache=Shared" + } +} diff --git a/test/Okkema.Test/AutoMockDataAttribute.cs b/test/Okkema.Test/AutoMockDataAttribute.cs new file mode 100755 index 0000000..06ba0cd --- /dev/null +++ b/test/Okkema.Test/AutoMockDataAttribute.cs @@ -0,0 +1,8 @@ +using AutoFixture; +using AutoFixture.Xunit2; +namespace Okkema.Test; +public class AutoMockDataAttribute : AutoDataAttribute +{ + public AutoMockDataAttribute() + :base(() => new Fixture()) {} +} \ No newline at end of file diff --git a/test/Okkema.Test/MockData.cs b/test/Okkema.Test/MockData.cs new file mode 100755 index 0000000..ac43ba3 --- /dev/null +++ b/test/Okkema.Test/MockData.cs @@ -0,0 +1,8 @@ +namespace Okkema.Test; +public class MockData +{ + public int Integer { get; set; } + public float Float { get; set; } + public string String { get; set; } = string.Empty; + public DateTime DateTime { get; set; } +} diff --git a/test/Okkema.Test/Okkema.Test.csproj b/test/Okkema.Test/Okkema.Test.csproj new file mode 100644 index 0000000..dd66398 --- /dev/null +++ b/test/Okkema.Test/Okkema.Test.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + +