From 43ccaf6f30af46308acedfb2cf98519bdedb28d6 Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Tue, 22 Oct 2024 14:54:42 +0300 Subject: [PATCH 1/9] init `feature/reporting-beta` - add release notes --- unreleased.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/unreleased.md b/unreleased.md index 10091eee..f53aa733 100644 --- a/unreleased.md +++ b/unreleased.md @@ -7,31 +7,33 @@ - `Configuration` - `DependencyInjection` - `Monitoring` - - `Oracle` implementation of `Database` feature is now added + - `Oracle` implementation of `Database` feature is now added + - `Reporting` feature is introduced with three implenmentations `NativeSql` + for production, `Mock` and `Fake` for development ## Improvements - `MockConfiguration` feature now clears `FakeSettings` list on teardown -- `MocMe.TheClient` helper now provides optional parameter to clear previous +- `MocMe.TheClient` helper now provides optional parameter to clear previous invocations -- `ConfigureAction` and `OverrideAction` helpers are now added to configure +- `ConfigureAction` and `OverrideAction` helpers are now added to configure `RestApi.ActionModel` before and after conventions - `Enum` helper class is added to use enum values within `ValueSource` attribute - `DataAccess` layer now exposes `FluentConfiguration` as configuration target -- `DataAccess` layer now introduces `IDatabaseInitializerCollection` +- `DataAccess` layer now introduces `IDatabaseInitializerCollection` configuration target for registering database initialization actions -- `IServiceProvider` now has `UseCurrentScope` extensions to resolve services - using the scope provided by `IServiceProviderAccessor` implementations -- `TestRun` now creates and disposes a scope for each test run to -- `Runtime` layer now provides `IFileProvider`component with +- `IServiceProvider` now has `UseCurrentScope` extensions to resolve services + using the scope provided by `IServiceProviderAccessor` implementations +- `TestRun` now creates and disposes a scope for each test run to +- `Runtime` layer now provides `IFileProvider`component with `CompositeFileProvider` implementation - `ReadAsString` and `ReadAsStringAsync` helper extensions are now added for - `IFileProvider` -- `DomainAssemblies` feature now have options to auto add embedded file - providers for give assemblies + `IFileProvider` +- `DomainAssemblies` feature now have options to auto add embedded file + providers for give assemblies - `Dotnet` feature now adds embedded and physical file providers for given - entry assembly + entry assembly ### Library Upgrades @@ -47,4 +49,4 @@ | NHibernate.Extensions.Sqlite | 8.0.13 | 8.0.14 | | Npgsql | 8.0.4 | 8.0.5 | | Swashbuckle.AspNetCore | 6.8.0 | 6.9.0 | -| Swashbuckle.AspNetCore.Annotations | 6.8.0 | 6.9.0 | \ No newline at end of file +| Swashbuckle.AspNetCore.Annotations | 6.8.0 | 6.9.0 | From aec536c76f9281dbf7970f62e7190ed9a79eafdf Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Tue, 22 Oct 2024 16:44:18 +0300 Subject: [PATCH 2/9] add native sql reporting --- .../NativeSql/NativeSqlReportingExtensions.cs | 12 +++++++++ .../NativeSql/NativeSqlReportingFeature.cs | 21 +++++++++++++++ .../Reporting/NativeSql/ReportContext.cs | 27 +++++++++++++++++++ .../Reporting/NativeSql/ReportSettings.cs | 5 ++++ .../Reporting/ReportingConfigurator.cs | 3 +++ .../Reporting/ReportingExtensions.cs | 10 +++++++ .../Reporting/IReportContext.cs | 6 +++++ .../Program.cs | 11 ++++++-- .../Reporting/FetchingReportUsingNativeSql.cs | 20 ++++++++++++++ .../Runtime/ReadingResources.cs | 25 ++++++++--------- .../Baked.Test.Recipe.Service.csproj | 5 ++-- .../Reporting/EntityReportData.cs | 3 +++ .../Reporting/ReportSamples.cs | 13 +++++++++ .../Reporting/Sqlite/entity.sql | 7 +++++ 14 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs create mode 100644 src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs new file mode 100644 index 00000000..0cfd04e6 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs @@ -0,0 +1,12 @@ +using Baked.Reporting; +using Baked.Reporting.NativeSql; +using Baked.Runtime; + +namespace Baked; + +public static class NativeSqlReportingExtensions +{ + public static NativeSqlReportingFeature NativeSql(this ReportingConfigurator _, + Setting? basePath = default + ) => new(basePath ?? "/"); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs new file mode 100644 index 00000000..d5a2b93e --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs @@ -0,0 +1,21 @@ +using Baked.Architecture; +using Baked.Runtime; +using Microsoft.Extensions.DependencyInjection; +using NHibernate; + +namespace Baked.Reporting.NativeSql; + +public class NativeSqlReportingFeature(Setting _basePath) + : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureServiceCollection(services => + { + services.AddSingleton(new ReportSettings(_basePath)); + services.AddSingleton(); + services.AddScoped(sp => sp.GetRequiredService().OpenStatelessSession()); + services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs new file mode 100644 index 00000000..572b5dbe --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.FileProviders; + +namespace Baked.Reporting.NativeSql; + +public class ReportContext(IFileProvider _fileProvider, Func _getStatelessSession, ReportSettings _options) + : IReportContext +{ + public async Task Read(string queryName, Dictionary parameters) + { + var queryPath = $"/{Path.Join(_options.BasePath, $"{queryName}.sql")}"; + if (!_fileProvider.GetFileInfo(queryPath).Exists) + { + throw new("no query"); + } + + var queryString = await _fileProvider.ReadAsStringAsync(queryPath); + var query = _getStatelessSession().CreateSQLQuery(queryString); + foreach (var (name, value) in parameters) + { + query.SetParameter(name, value); + } + + var result = await query.ListAsync(); + + return result.Cast().ToArray(); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs new file mode 100644 index 00000000..36d1f07a --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs @@ -0,0 +1,5 @@ +using Baked.Runtime; + +namespace Baked.Reporting.NativeSql; + +public record ReportSettings(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs new file mode 100644 index 00000000..59055014 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs @@ -0,0 +1,3 @@ +namespace Baked.Reporting; + +public class ReportingConfigurator { } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs new file mode 100644 index 00000000..58f77e11 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs @@ -0,0 +1,10 @@ +using Baked.Architecture; +using Baked.Reporting; + +namespace Baked; + +public static class ReportingExtensions +{ + public static void AddReporting(this List features, Func> configure) => + features.Add(configure(new())); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs new file mode 100644 index 00000000..1c2d4b4c --- /dev/null +++ b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs @@ -0,0 +1,6 @@ +namespace Baked.Reporting; + +public interface IReportContext +{ + Task Read(string queryName, Dictionary parameters); +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs index 3cfaa837..6d2831ff 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs @@ -34,7 +34,14 @@ .PostgreSql() .ForDevelopment(c.Sqlite()) .ForNfr(c.Sqlite(fileName: $"Baked.Test.Recipe.Service.Nfr.db")), - exceptionHandling: ex => ex.Default(typeUrlFormat: "https://baked.mouseless.codes/errors/{0}"), - configure: app => app.Features.AddConfigurationOverrider() + exceptionHandling: c => c.Default(typeUrlFormat: "https://baked.mouseless.codes/errors/{0}"), + configure: app => + { + app.Features.AddReporting(c => c + .NativeSql(basePath: "Reporting/Sqlite") + .ForProduction(c.NativeSql(basePath: "Reporting/PostgreSql")) + ); + app.Features.AddConfigurationOverrider(); + } ) .Run(); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs new file mode 100644 index 00000000..93d12814 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System.Net.Http.Json; + +namespace Baked.Test.Runtime; + +public class FetchingReportUsingNativeSql : TestServiceNfr +{ + [Test] + public async Task Loads_query_from_resource_and_fetches_data_from_db() + { + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-2" })); + + var response = await Client.GetAsync("report-samples/entity?name=test"); + dynamic? content = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + ((string?)content?.name).ShouldBe("test"); + ((int?)content?.count).ShouldBe(2); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs index afc34e8d..7eb16da2 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs @@ -4,36 +4,33 @@ namespace Baked.Test.Runtime; public class ReadingResources : TestServiceNfr { - [TestCase("/Core/ApplicationPhysical.txt", "\"application physical\"")] - [TestCase("/Core/ApplicationEmbedded.txt", "\"application embedded\"")] - [TestCase("/Core/DomainEmbedded.txt", "\"domain embedded\"")] + [TestCase("/Core/ApplicationPhysical.txt", "application physical")] + [TestCase("/Core/ApplicationEmbedded.txt", "application embedded")] + [TestCase("/Core/DomainEmbedded.txt", "domain embedded")] public async Task Contents_of_a_resource_can_be_read(string subPath, string expected) { - Client.DefaultRequestHeaders.Authorization = GetFixedBearerToken("BaseClaims"); - var response = await Client.PostAsync("resource-samples/read", JsonContent.Create(new { subPath })); - var content = await response.Content.ReadAsStringAsync(); + var content = await response.Content.ReadFromJsonAsync(); content.ShouldBe(expected); response = await Client.PostAsync("resource-samples/read-async", JsonContent.Create(new { subPath })); - content = await response.Content.ReadAsStringAsync(); + content = await response.Content.ReadFromJsonAsync(); content.ShouldBe(expected); } - public async Task Returns_null_when_embedded_resource_does_not_exits() + [Test] + public async Task Returns_empty_string_when_embedded_resource_does_not_exits() { - Client.DefaultRequestHeaders.Authorization = GetFixedBearerToken("BaseClaims"); - var response = await Client.PostAsync("resource-samples/read", JsonContent.Create(new { subPath = "NotExistingResource.txt" })); - var content = await response.Content.ReadAsStringAsync(); + var content = await response.Content.ReadFromJsonAsync(); - content.ShouldBeNull(); + content.ShouldBeEmpty(); response = await Client.PostAsync("resource-samples/read-async", JsonContent.Create(new { subPath = "NotExistingResource.txt" })); - content = await response.Content.ReadAsStringAsync(); + content = await response.Content.ReadFromJsonAsync(); - content.ShouldBeNull(); + content.ShouldBeEmpty(); } } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj index 6ac1dd41..56626cb6 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj +++ b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj @@ -6,15 +6,16 @@ + - + - + diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs new file mode 100644 index 00000000..703c4340 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs @@ -0,0 +1,3 @@ +namespace Baked.Test.Reporting; + +public record EntityReportData(string Name, int Count); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs new file mode 100644 index 00000000..6559b30d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs @@ -0,0 +1,13 @@ +using Baked.Reporting; + +namespace Baked.Test.Reporting; + +public class ReportSamples(IReportContext _context) +{ + public async Task GetEntity(string name) + { + var result = await _context.Read("entity", new() { { nameof(name), $"{name}%" } }); + + return new(name, Convert.ToInt32(result[0][0])); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql new file mode 100644 index 00000000..431fa5d3 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql @@ -0,0 +1,7 @@ +SELECT + count(Id), + :name +FROM + Entity +WHERE + String LIKE :name From ea22bf379998b1058e972cabac73a5323663d8a9 Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 10:47:23 +0300 Subject: [PATCH 3/9] make improvements regarding reporting feature - rename report settings back to options to match token options naming - make entity report return multiple rows using group by - provide postgresql and mysql queries - add nhibernate levels to native sql - additionally - move nh session registration to orm - use stateless session in db initialization - provide async for should fail and pass --- .../Testing/Extensions/AssertionExtensions.cs | 12 ++++++++++++ .../DataAccess/DataAccessLayer.cs | 2 -- .../Database/InMemory/InMemoryDatabaseFeature.cs | 5 ++--- .../Orm/AutoMap/AutoMapOrmFeature.cs | 2 ++ .../NativeSql/NativeSqlReportingExtensions.cs | 2 +- .../NativeSql/NativeSqlReportingFeature.cs | 16 +++++++++++++++- .../Reporting/NativeSql/ReportContext.cs | 6 +++--- .../Reporting/NativeSql/ReportOptions.cs | 5 +++++ .../Reporting/NativeSql/ReportSettings.cs | 5 ----- .../Reporting/IReportContext.cs | 2 +- .../Runtime/FileProviderExtensions.cs | 15 +++++++++------ .../Dockerfile | 2 +- .../Reporting/FetchingReportUsingNativeSql.cs | 7 +++++-- .../TestServiceSpec.cs | 3 ++- .../Baked.Test.Recipe.Service.csproj | 1 + .../Reporting/MySql/entity.sql | 9 +++++++++ .../Reporting/PostgreSql/entity.sql | 9 +++++++++ .../Reporting/ReportSamples.cs | 10 ++++------ .../Reporting/Sqlite/entity.sql | 10 ++++++---- unreleased.md | 1 + 20 files changed, 88 insertions(+), 36 deletions(-) create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs delete mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql diff --git a/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs b/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs index d0621c25..3de4e7a7 100644 --- a/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs +++ b/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs @@ -10,7 +10,19 @@ public static class AssertionExtensions public static void ShouldFail(this Spec _, string message = "") => throw new AssertionException(message); + [DoesNotReturn] + public static Task ShouldFailAsync(this Spec _, string message = "") => + throw new AssertionException(message); + [DoesNotReturn] public static void ShouldPass(this Spec _, string message = "") => Assert.Pass(message); + + [DoesNotReturn] + public static Task ShouldPassAsync(this Spec _, string message = "") + { + Assert.Pass(message); + + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs b/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs index 645d3160..978aa82c 100644 --- a/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs +++ b/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs @@ -43,8 +43,6 @@ protected override PhaseContext GetContext(AddServices phase) services.AddSingleton(sp => _fluentConfiguration.BuildConfiguration()); services.AddSingleton(sp => sp.GetRequiredService().BuildSessionFactory()); - services.AddScoped(sp => sp.GetRequiredService().OpenSession()); - services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); }) .Build(); } diff --git a/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs index bf2e4ebe..df835b52 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs @@ -9,7 +9,7 @@ namespace Baked.Database.InMemory; public class InMemoryDatabaseFeature : IFeature { - ISession? _globalSession; + IStatelessSession? _globalSession; public void Configure(LayerConfigurator configurator) { @@ -28,7 +28,7 @@ public void Configure(LayerConfigurator configurator) initializations.AddInitializer(sf => { // In memory db is disposed when last connection is closed, this connection is to keep the db open - _globalSession = sf.OpenSession(); + _globalSession = sf.OpenStatelessSession(); sp.GetRequiredService().ExportSchema(false, true, false, _globalSession.Connection); }); @@ -48,6 +48,5 @@ public void Configure(LayerConfigurator configurator) spec.GiveMe.TheSession().Clear(); }); }); - } } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs index e443f6dc..74024135 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs @@ -70,6 +70,8 @@ public void Configure(LayerConfigurator configurator) configurator.ConfigureServiceCollection(services => { services.AddFromAssembly(configurator.Context.GetGeneratedAssembly(nameof(AutoMapOrmFeature))); + services.AddScoped(sp => sp.GetRequiredService().OpenSession()); + services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); services.AddScoped(typeof(IEntityContext<>), typeof(EntityContext<>)); services.AddSingleton(typeof(IQueryContext<>), typeof(QueryContext<>)); }); diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs index 0cfd04e6..a05db08d 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs @@ -8,5 +8,5 @@ public static class NativeSqlReportingExtensions { public static NativeSqlReportingFeature NativeSql(this ReportingConfigurator _, Setting? basePath = default - ) => new(basePath ?? "/"); + ) => new(basePath ?? string.Empty); } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs index d5a2b93e..349eac82 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs @@ -10,9 +10,23 @@ public class NativeSqlReportingFeature(Setting _basePath) { public void Configure(LayerConfigurator configurator) { + configurator.ConfigureConfigurationBuilder(configuration => + { + configuration.AddJsonAsDefault($$""" + { + "Logging": { + "LogLevel": { + "NHibernate": "None", + "NHibernate.Sql": "{{(configurator.IsDevelopment() ? "Debug" : "None")}}" + } + } + } + """); + }); + configurator.ConfigureServiceCollection(services => { - services.AddSingleton(new ReportSettings(_basePath)); + services.AddSingleton(new ReportOptions(_basePath)); services.AddSingleton(); services.AddScoped(sp => sp.GetRequiredService().OpenStatelessSession()); services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs index 572b5dbe..2e203835 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs @@ -2,13 +2,13 @@ namespace Baked.Reporting.NativeSql; -public class ReportContext(IFileProvider _fileProvider, Func _getStatelessSession, ReportSettings _options) +public class ReportContext(IFileProvider _fileProvider, Func _getStatelessSession, ReportOptions _options) : IReportContext { - public async Task Read(string queryName, Dictionary parameters) + public async Task Execute(string queryName, Dictionary parameters) { var queryPath = $"/{Path.Join(_options.BasePath, $"{queryName}.sql")}"; - if (!_fileProvider.GetFileInfo(queryPath).Exists) + if (!_fileProvider.Exists(queryPath)) { throw new("no query"); } diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs new file mode 100644 index 00000000..fc2be972 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs @@ -0,0 +1,5 @@ +using Baked.Runtime; + +namespace Baked.Reporting.NativeSql; + +public record ReportOptions(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs deleted file mode 100644 index 36d1f07a..00000000 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportSettings.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Baked.Runtime; - -namespace Baked.Reporting.NativeSql; - -public record ReportSettings(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs index 1c2d4b4c..38245583 100644 --- a/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs +++ b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs @@ -2,5 +2,5 @@ namespace Baked.Reporting; public interface IReportContext { - Task Read(string queryName, Dictionary parameters); + Task Execute(string queryName, Dictionary parameters); } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs b/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs index f68f00a0..f74e9be4 100644 --- a/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs +++ b/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs @@ -4,23 +4,26 @@ namespace Microsoft.Extensions.FileProviders; public static class FileProviderExtensions { - public static string? ReadAsString(this IFileProvider provider, string subPath) + public static bool Exists(this IFileProvider provider, string subpath) => + provider.GetFileInfo(subpath).Exists; + + public static string? ReadAsString(this IFileProvider provider, string subpath) { - using var streamReader = provider.CreateStreamReader(subPath); + using var streamReader = provider.CreateStreamReader(subpath); return streamReader.ReadToEnd(); } - public static async Task ReadAsStringAsync(this IFileProvider provider, string subPath) + public static async Task ReadAsStringAsync(this IFileProvider provider, string subpath) { - using var streamReader = provider.CreateStreamReader(subPath); + using var streamReader = provider.CreateStreamReader(subpath); return await streamReader.ReadToEndAsync(); } - static StreamReader CreateStreamReader(this IFileProvider provider, string subPath) + static StreamReader CreateStreamReader(this IFileProvider provider, string subpath) { - var fileInfo = provider.GetFileInfo(subPath); + var fileInfo = provider.GetFileInfo(subpath); if (!fileInfo.Exists) { return StreamReader.Null; } return new(fileInfo.CreateReadStream(), Encoding.UTF8); diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile b/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile index b338f454..2297b2cb 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile +++ b/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile @@ -10,7 +10,7 @@ ENV ASPNETCORE_URLS=http://+:80 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS publish -COPY --exclude=**/*.cs --exclude=**/*.json --exclude=**/*.md --exclude=**/*.yml --exclude=Makefile . . +COPY --exclude=**/*.cs --exclude=**/*.sql --exclude=**/*.json --exclude=**/*.md --exclude=**/*.yml --exclude=Makefile . . RUN dotnet restore COPY . . diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs index 93d12814..94481ea0 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs @@ -8,13 +8,16 @@ public class FetchingReportUsingNativeSql : TestServiceNfr [Test] public async Task Loads_query_from_resource_and_fetches_data_from_db() { + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-2" })); var response = await Client.GetAsync("report-samples/entity?name=test"); dynamic? content = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - ((string?)content?.name).ShouldBe("test"); - ((int?)content?.count).ShouldBe(2); + ((int?)content?[0].count).ShouldBe(2); + ((string?)content?[0].name).ShouldBe("test-1"); + ((int?)content?[1].count).ShouldBe(1); + ((string?)content?[1].name).ShouldBe("test-2"); } } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs b/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs index 4e793074..ae9c0022 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs @@ -7,7 +7,7 @@ public abstract class TestServiceSpec : ServiceSpec { static TestServiceSpec() => Init( - business: c => c.DomainAssemblies([typeof(Entity).Assembly]), + business: c => c.DomainAssemblies([typeof(Entity).Assembly], baseNamespace: "Baked.Test"), communication: c => c.Mock(defaultResponses: response => { response.ForClient(response: "test result"); @@ -16,6 +16,7 @@ static TestServiceSpec() => }), configure: app => { + app.Features.AddReporting(c => c.Mock()); app.Features.AddConfigurationOverrider(); } ); diff --git a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj index 56626cb6..8d7cf997 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj +++ b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj @@ -7,6 +7,7 @@ + diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql new file mode 100644 index 00000000..c9a0835d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql @@ -0,0 +1,9 @@ +SELECT + count(e.Id), + e.String +FROM + Entity e +WHERE + e.String LIKE :name +GROUP BY + e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql new file mode 100644 index 00000000..c9a0835d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql @@ -0,0 +1,9 @@ +SELECT + count(e.Id), + e.String +FROM + Entity e +WHERE + e.String LIKE :name +GROUP BY + e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs index 6559b30d..ddcd5e46 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs @@ -4,10 +4,8 @@ namespace Baked.Test.Reporting; public class ReportSamples(IReportContext _context) { - public async Task GetEntity(string name) - { - var result = await _context.Read("entity", new() { { nameof(name), $"{name}%" } }); - - return new(name, Convert.ToInt32(result[0][0])); - } + public async Task> GetEntity(string name) => + (await _context.Execute("entity", new() { { nameof(name), $"{name}%" } })) + .Select(row => new EntityReportData((string?)row[1] ?? string.Empty, Convert.ToInt32(row[0]))) + .ToList(); } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql index 431fa5d3..c9a0835d 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql @@ -1,7 +1,9 @@ SELECT - count(Id), - :name + count(e.Id), + e.String FROM - Entity + Entity e WHERE - String LIKE :name + e.String LIKE :name +GROUP BY + e.String diff --git a/unreleased.md b/unreleased.md index f53aa733..7f2716e0 100644 --- a/unreleased.md +++ b/unreleased.md @@ -34,6 +34,7 @@ providers for give assemblies - `Dotnet` feature now adds embedded and physical file providers for given entry assembly +- Async overloads for `ShouldPass` and `ShouldFail` are now available ### Library Upgrades From 2215145b2196959ffc0311e16b5a4d6144b3d9ae Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 10:52:19 +0300 Subject: [PATCH 4/9] implement fake reporting feature - add empty mock reporting feature --- .../Reporting/Fake/FakeData.cs | 29 +++++++ .../Reporting/Fake/FakeReportingExtensions.cs | 16 ++++ .../Reporting/Fake/FakeReportingFeature.cs | 15 ++++ .../Reporting/Fake/ReportContext.cs | 22 ++++++ .../Reporting/Fake/ReportOptions.cs | 5 ++ .../Reporting/Mock/MockReportingExtensions.cs | 10 +++ .../Reporting/Mock/MockReportingFeature.cs | 14 ++++ .../Reporting/QueryNotFoundException.cs | 4 + .../Reporting/FakingReportResults.cs | 75 +++++++++++++++++++ .../Reporting/MockingReportResults.cs | 9 +++ .../Reporting/Fake/entity.json | 23 ++++++ 11 files changed, 222 insertions(+) create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs create mode 100644 src/recipe/Baked.Recipe.Service.Application/Reporting/QueryNotFoundException.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs create mode 100644 test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs new file mode 100644 index 00000000..b8e0aff5 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; + +namespace Baked.Reporting.Fake; + +public record FakeData( + Dictionary? Parameters, + List> Result +) +{ + public bool Matches(Dictionary parameters) + { + if (Parameters is null) { return true; } + + foreach (var (key, pattern) in Parameters) + { + if (!parameters.ContainsKey(key)) + { + return false; + } + + if (pattern is null) { continue; } + if (Regex.IsMatch($"{parameters[key]}", pattern)) { continue; } + + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs new file mode 100644 index 00000000..3f664450 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs @@ -0,0 +1,16 @@ +using Baked.Reporting; +using Baked.Reporting.Fake; +using Baked.Testing; +using Microsoft.Extensions.FileProviders; + +namespace Baked; + +public static class FakeReportingExtensions +{ + public static FakeReportingFeature Fake(this ReportingConfigurator _) => + new(); + + public static IReportContext AFakeReportContext(this Stubber giveMe, + string basePath = "Fake" + ) => new ReportContext(giveMe.The(), new(basePath)); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs new file mode 100644 index 00000000..39a22efd --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs @@ -0,0 +1,15 @@ +using Baked.Architecture; +using Microsoft.Extensions.DependencyInjection; + +namespace Baked.Reporting.Fake; + +public class FakeReportingFeature : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureServiceCollection(services => + { + services.AddSingleton(); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs new file mode 100644 index 00000000..b12fc705 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.FileProviders; +using Newtonsoft.Json; + +namespace Baked.Reporting.Fake; + +public class ReportContext(IFileProvider _fileProvider, ReportOptions _options) + : IReportContext +{ + public async Task Execute(string queryName, Dictionary parameters) + { + var dataPath = $"/{Path.Join(_options.BasePath, $"{queryName}.json")}"; + if (!_fileProvider.Exists(dataPath)) { throw new QueryNotFoundException(queryName); } + + var dataString = await _fileProvider.ReadAsStringAsync(dataPath) ?? string.Empty; + + var fakes = JsonConvert.DeserializeObject>(dataString) ?? new(); + var match = fakes.FirstOrDefault(fake => fake.Matches(parameters)); + if (match is null) { return []; } + + return match.Result.Select(row => row.Values.ToArray()).ToArray(); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs new file mode 100644 index 00000000..97b6f7ce --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs @@ -0,0 +1,5 @@ +using Baked.Runtime; + +namespace Baked.Reporting.Fake; + +public record ReportOptions(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs new file mode 100644 index 00000000..3e563f99 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs @@ -0,0 +1,10 @@ +using Baked.Reporting; +using Baked.Reporting.Mock; + +namespace Baked; + +public static class MockReportingExtensions +{ + public static MockReportingFeature Mock(this ReportingConfigurator _) => + new(); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs new file mode 100644 index 00000000..547a8f15 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs @@ -0,0 +1,14 @@ +using Baked.Architecture; + +namespace Baked.Reporting.Mock; + +public class MockReportingFeature : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureTestConfiguration(test => + { + test.Mocks.Add(singleton: true); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/QueryNotFoundException.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/QueryNotFoundException.cs new file mode 100644 index 00000000..f755bd52 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/QueryNotFoundException.cs @@ -0,0 +1,4 @@ +namespace Baked.Reporting; + +public class QueryNotFoundException(string queryName) + : Exception($"No query file with '{queryName}' was found"); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs new file mode 100644 index 00000000..3a5be9bb --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs @@ -0,0 +1,75 @@ +using Baked.Reporting; + +namespace Baked.Test.Reporting; + +public class FakingReportResults : TestServiceSpec +{ + [Test] + public async Task Loads_fake_data_from_resource_using_configured_base_path() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "name", "test" } }); + + result.Length.ShouldBePositive(); + } + + [Test] + public void When_report_was_not_found_throws_query_not_found_exception() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var action = context.Execute("non-existing", []); + + action.ShouldThrow().Message.ShouldContain("non-existing"); + } + + [Test] + public async Task Clears_keys_from_rows__returning_just_cells() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "name", "test" } }); + + result[0][0].ShouldDeeplyBe(2); + result[0][1].ShouldDeeplyBe("test 1"); + result[1][0].ShouldDeeplyBe(1); + result[1][1].ShouldDeeplyBe("test 2"); + } + + [Test] + public async Task Uses_argument_values_to_find_different_fake_data_for_the_same_report() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "name", "filtered" } }); + + result[0][0].ShouldDeeplyBe(4); + result[0][1].ShouldDeeplyBe("filtered 1"); + result[1][0].ShouldDeeplyBe(3); + result[1][1].ShouldDeeplyBe("filtered 2"); + } + + [Test] + public async Task Argument_matchers_uses_regex_patterns_to_allow_wildcard_like_expressions() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "name", "reg" } }); + + result[0][0].ShouldDeeplyBe(6); + result[0][1].ShouldDeeplyBe("reg-x"); + result[1][0].ShouldDeeplyBe(5); + result[1][1].ShouldDeeplyBe("reg-y"); + } + + [Test] + public async Task When_no_data_matches_argument_returns_empty_array() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "name", "non-existing" } }); + + result.ShouldBeEmpty(); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs new file mode 100644 index 00000000..ad9f62a0 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs @@ -0,0 +1,9 @@ +namespace Baked.Test.Reporting; + +public class MockingReportResults : TestServiceSpec +{ + [Test] + [Ignore("not implemented")] + public async Task Will_do() => + await this.ShouldFailAsync(); +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json new file mode 100644 index 00000000..bee4f1ca --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json @@ -0,0 +1,23 @@ +[ + { + "parameters": { "name": "test" }, + "result": [ + { "count" : 2, "name" : "test 1" }, + { "count" : 1, "name" : "test 2" } + ] + }, + { + "parameters": { "name": "filtered" }, + "result": [ + { "count" : 4, "name" : "filtered 1" }, + { "count" : 3, "name" : "filtered 2" } + ] + }, + { + "parameters": { "name": "reg.*" }, + "result": [ + { "count" : 6, "name" : "reg-x" }, + { "count" : 5, "name" : "reg-y" } + ] + } +] From ae1747216c327f63aee670a50ade974caff7474e Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 11:00:37 +0300 Subject: [PATCH 5/9] simplify program.cs and move sqlite db name to settings --- .../Baked.Test.Recipe.Service.Application/Program.cs | 8 ++++---- .../appsettings.Nfr.json | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs index 6d2831ff..e005ba5a 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs @@ -28,12 +28,12 @@ claims: ["User", "Admin", "BaseA", "BaseB", "GivenA", "GivenB", "GivenC"], baseClaims: ["BaseA", "BaseB"] ), - core: c => c.Dotnet(baseNamespace: _ => "Baked.Test") + core: c => c + .Dotnet(baseNamespace: _ => "Baked.Test") .ForNfr(c.Dotnet(entryAssembly: Assembly.GetExecutingAssembly(), baseNamespace: _ => "Baked.Test")), database: c => c - .PostgreSql() - .ForDevelopment(c.Sqlite()) - .ForNfr(c.Sqlite(fileName: $"Baked.Test.Recipe.Service.Nfr.db")), + .Sqlite() + .ForProduction(c.PostgreSql()), exceptionHandling: c => c.Default(typeUrlFormat: "https://baked.mouseless.codes/errors/{0}"), configure: app => { diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json b/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json new file mode 100644 index 00000000..7162117e --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json @@ -0,0 +1,7 @@ +{ + "Database": { + "Sqlite": { + "FileName": "Baked.Test.Recipe.Service.Nfr.db" + } + } +} From 395574adf0fc59c06fc2afed723772e1026e7537 Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 11:14:12 +0300 Subject: [PATCH 6/9] rename name to match entity props in report - add a note in fake report tests to state where data comes from --- .../Reporting/FakingReportResults.cs | 15 ++++++++++----- .../Reporting/FetchingReportUsingNativeSql.cs | 6 +++--- .../Reporting/EntityReportData.cs | 2 +- .../Reporting/Fake/entity.json | 18 +++++++++--------- .../Reporting/MySql/entity.sql | 2 +- .../Reporting/PostgreSql/entity.sql | 2 +- .../Reporting/ReportSamples.cs | 18 ++++++++++++++---- .../Reporting/Sqlite/entity.sql | 2 +- 8 files changed, 40 insertions(+), 25 deletions(-) diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs index 3a5be9bb..bafea8d9 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs @@ -2,6 +2,11 @@ namespace Baked.Test.Reporting; +/// +/// Fake data is in `Baked.Test.Recipe.Service/Reporting/Fake/entity.json`. +/// Look the data in this json file to understand why fake report context +/// returns below expected results. +/// public class FakingReportResults : TestServiceSpec { [Test] @@ -9,7 +14,7 @@ public async Task Loads_fake_data_from_resource_using_configured_base_path() { var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); - var result = await context.Execute("entity", new() { { "name", "test" } }); + var result = await context.Execute("entity", new() { { "string", "test" } }); result.Length.ShouldBePositive(); } @@ -29,7 +34,7 @@ public async Task Clears_keys_from_rows__returning_just_cells() { var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); - var result = await context.Execute("entity", new() { { "name", "test" } }); + var result = await context.Execute("entity", new() { { "string", "test" } }); result[0][0].ShouldDeeplyBe(2); result[0][1].ShouldDeeplyBe("test 1"); @@ -42,7 +47,7 @@ public async Task Uses_argument_values_to_find_different_fake_data_for_the_same_ { var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); - var result = await context.Execute("entity", new() { { "name", "filtered" } }); + var result = await context.Execute("entity", new() { { "string", "filtered" } }); result[0][0].ShouldDeeplyBe(4); result[0][1].ShouldDeeplyBe("filtered 1"); @@ -55,7 +60,7 @@ public async Task Argument_matchers_uses_regex_patterns_to_allow_wildcard_like_e { var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); - var result = await context.Execute("entity", new() { { "name", "reg" } }); + var result = await context.Execute("entity", new() { { "string", "reg" } }); result[0][0].ShouldDeeplyBe(6); result[0][1].ShouldDeeplyBe("reg-x"); @@ -68,7 +73,7 @@ public async Task When_no_data_matches_argument_returns_empty_array() { var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); - var result = await context.Execute("entity", new() { { "name", "non-existing" } }); + var result = await context.Execute("entity", new() { { "string", "non-existing" } }); result.ShouldBeEmpty(); } diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs index 94481ea0..32b45171 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs @@ -12,12 +12,12 @@ public async Task Loads_query_from_resource_and_fetches_data_from_db() await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-2" })); - var response = await Client.GetAsync("report-samples/entity?name=test"); + var response = await Client.GetAsync("report-samples/entity?string=test"); dynamic? content = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); ((int?)content?[0].count).ShouldBe(2); - ((string?)content?[0].name).ShouldBe("test-1"); + ((string?)content?[0].@string).ShouldBe("test-1"); ((int?)content?[1].count).ShouldBe(1); - ((string?)content?[1].name).ShouldBe("test-2"); + ((string?)content?[1].@string).ShouldBe("test-2"); } } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs index 703c4340..e76d083d 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs @@ -1,3 +1,3 @@ namespace Baked.Test.Reporting; -public record EntityReportData(string Name, int Count); \ No newline at end of file +public record EntityReportData(int Count, string String); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json index bee4f1ca..114c9393 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json @@ -1,23 +1,23 @@ [ { - "parameters": { "name": "test" }, + "parameters": { "string": "test" }, "result": [ - { "count" : 2, "name" : "test 1" }, - { "count" : 1, "name" : "test 2" } + { "count" : 2, "string" : "test 1" }, + { "count" : 1, "string" : "test 2" } ] }, { - "parameters": { "name": "filtered" }, + "parameters": { "string": "filtered" }, "result": [ - { "count" : 4, "name" : "filtered 1" }, - { "count" : 3, "name" : "filtered 2" } + { "count" : 4, "string" : "filtered 1" }, + { "count" : 3, "string" : "filtered 2" } ] }, { - "parameters": { "name": "reg.*" }, + "parameters": { "string": "reg.*" }, "result": [ - { "count" : 6, "name" : "reg-x" }, - { "count" : 5, "name" : "reg-y" } + { "count" : 6, "string" : "reg-x" }, + { "count" : 5, "string" : "reg-y" } ] } ] diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql index c9a0835d..b9cae181 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql @@ -4,6 +4,6 @@ SELECT FROM Entity e WHERE - e.String LIKE :name + e.String LIKE :string GROUP BY e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql index c9a0835d..b9cae181 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql @@ -4,6 +4,6 @@ SELECT FROM Entity e WHERE - e.String LIKE :name + e.String LIKE :string GROUP BY e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs index ddcd5e46..42a19d24 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs @@ -4,8 +4,18 @@ namespace Baked.Test.Reporting; public class ReportSamples(IReportContext _context) { - public async Task> GetEntity(string name) => - (await _context.Execute("entity", new() { { nameof(name), $"{name}%" } })) - .Select(row => new EntityReportData((string?)row[1] ?? string.Empty, Convert.ToInt32(row[0]))) - .ToList(); + public async Task> GetEntity(string @string) => + (await _context.Execute("entity", + new() + { + { nameof(@string), $"{@string}%" } + } + )) + .Select(row => + new EntityReportData( + Convert.ToInt32(row[0]), + (string?)row[1] ?? string.Empty + ) + ) + .ToList(); } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql index c9a0835d..b9cae181 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql @@ -4,6 +4,6 @@ SELECT FROM Entity e WHERE - e.String LIKE :name + e.String LIKE :string GROUP BY e.String From b7fdc40ba98ab8708796ed749cc1f14da2386338 Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 17:08:17 +0300 Subject: [PATCH 7/9] implement mock reporting feature --- .../Reporting/ReportingExtensions.cs | 38 +++++++++++++++++ .../Reporting/MockingReportResults.cs | 41 +++++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs index 58f77e11..8f45193c 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs @@ -1,5 +1,7 @@ using Baked.Architecture; using Baked.Reporting; +using Baked.Testing; +using Moq; namespace Baked; @@ -7,4 +9,40 @@ public static class ReportingExtensions { public static void AddReporting(this List features, Func> configure) => features.Add(configure(new())); + + public static IReportContext TheReportContext(this Mocker mockMe, + object?[][]? data = default + ) + { + data ??= []; + + var result = Mock.Get(mockMe.Spec.GiveMe.The()); + + if (data is not null) + { + result + .Setup(df => df.Execute(It.IsAny(), It.IsAny>())) + .ReturnsAsync(data); + } + + return result.Object; + } + + public static void VerifyExecute(this IReportContext dataFetcher, + string? queryName = default, + (string key, object value)? parameter = default, + List<(string key, object value)>? parameters = default + ) + { + parameters ??= parameter is not null ? [parameter.Value] : []; + + Mock.Get(dataFetcher).Verify( + df => df.Execute( + It.Is(q => queryName == null || q == queryName), + It.Is>(p => + parameters.All((kvp) => p.ContainsKey(kvp.key) && Equals(p[kvp.key], kvp.value)) + ) + ) + ); + } } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs index ad9f62a0..0ec8d234 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs @@ -1,9 +1,44 @@ +using Baked.Reporting; + namespace Baked.Test.Reporting; public class MockingReportResults : TestServiceSpec { [Test] - [Ignore("not implemented")] - public async Task Will_do() => - await this.ShouldFailAsync(); + public void Mock_report_context_is_provided_during_tests() + { + var context = GiveMe.The(); + + var getMock = () => Mock.Get(context); + + getMock.ShouldNotThrow(); + } + + [Test] + public async Task Sets_up_mock_report_to_return_desired_data() + { + MockMe.TheReportContext(data: [[2, "test-1"], [1, "test-2"]]); + var reportSamples = GiveMe.The(); + + var result = await reportSamples.GetEntity("test"); + + result[0].Count.ShouldBe(2); + result[0].String.ShouldBe("test-1"); + result[1].Count.ShouldBe(1); + result[1].String.ShouldBe("test-2"); + } + + [Test] + public async Task Verifies_execute_with_given_query_and_parameters() + { + var reportContext = MockMe.TheReportContext(); + var reportSamples = GiveMe.The(); + + await reportSamples.GetEntity("test"); + + reportContext.VerifyExecute( + queryName: "entity", + parameter: ("string", "test%") + ); + } } \ No newline at end of file From 6450e57c35a0978f5b2c3874a1be5c091e4e6b29 Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 17:21:52 +0300 Subject: [PATCH 8/9] add reporting feature docs --- docs/features/reporting.md | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/features/reporting.md diff --git a/docs/features/reporting.md b/docs/features/reporting.md new file mode 100644 index 00000000..41867e1d --- /dev/null +++ b/docs/features/reporting.md @@ -0,0 +1,44 @@ +# Reporting + +Implementations of this feature registers a singleton `IReportContext` with +which you can read raw data from database. Add this feature using +`AddReporting()` extension; + +```csharp +app.Features.AddReporting(...); +``` + +## Fake + +Adds a fake report context that allows you to return data directly from `.json` +resources. + +```csharp +c => c.Fake(basePath: "Fake") +``` + +## Mock + +Adds a mock instance of report context to be used during spec tests. + +```csharp +c => c.Mock() +``` + +## Native SQL + +Adds a report context instance that uses a `IStatelessSession` instance to +execute native SQL queries read from `.sql` resources in your project. + +```csharp +c => c.NativeSql(basePath: "Queries/MySql") +``` + +> [!TIP] +> +> You may group your RDBMS specific queries in different folders, and use +> setting to specify which folder to use depending on environment. +> +> ```csharp +> c => c.NativeSql(basePath: Settings.Required("Reporting:NativeSql:BasePath")) +> ``` From 5f56cd3a10c816465ccc354f9ba001d96e97447e Mon Sep 17 00:00:00 2001 From: cihandeniz Date: Wed, 23 Oct 2024 17:52:29 +0300 Subject: [PATCH 9/9] fix remanining issues - move query not found to abstractions - test prod for mysql and postgresql - fix typo in release notes --- docs/recipes/service.md | 2 +- .../Reporting/NativeSql/ReportContext.cs | 2 +- .../Reporting/QueryNotFoundException.cs | 0 .../Reporting/FetchingReportUsingNativeSql.cs | 9 +++++++++ .../Reporting/ReportSamples.cs | 18 ++++++++++++++++++ unreleased.md | 2 +- 6 files changed, 30 insertions(+), 3 deletions(-) rename src/recipe/{Baked.Recipe.Service.Application => Baked.Recipe.Service}/Reporting/QueryNotFoundException.cs (100%) diff --git a/docs/recipes/service.md b/docs/recipes/service.md index 78b12bb1..736f213c 100644 --- a/docs/recipes/service.md +++ b/docs/recipes/service.md @@ -53,7 +53,7 @@ Bake.New | | With Method | | | Communication | :white_check_mark: HTTP | :white_check_mark: Mock | | Core | :white_check_mark: Dotnet | :white_check_mark: Mock | -| Cors(s) | :white_check_mark: Disabled | :no_entry: | +| Cors | :white_check_mark: Disabled | :no_entry: | | Database | :white_check_mark: Sqlite | :white_check_mark: In Memory | | Exception Handling | :white_check_mark: Default | :white_check_mark: | | Greeting | :white_check_mark: Swagger | :no_entry: | diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs index 2e203835..9acfb2c5 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs @@ -10,7 +10,7 @@ public class ReportContext(IFileProvider _fileProvider, Func> GetEntity(string @string) => ) ) .ToList(); + + public async Task GetNonExisting() + { + try + { + await _context.Execute("non-existing", []); + } + catch (QueryNotFoundException ex) + { + if (!ex.Message.Contains("non-existing")) { throw; } + + return; + } + catch + { + throw; + } + } } \ No newline at end of file diff --git a/unreleased.md b/unreleased.md index 07d845f3..8ffcc7a9 100644 --- a/unreleased.md +++ b/unreleased.md @@ -15,7 +15,7 @@ ## Improvements - `MockConfiguration` feature now clears `FakeSettings` list on teardown -- `MocMe.TheClient` helper now provides optional parameter to clear previous +- `MockMe.TheClient` helper now provides optional parameter to clear previous invocations - `ConfigureAction` and `OverrideAction` helpers are now added to configure `RestApi.ActionModel` before and after conventions