diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 228b3a6b..4dd6d7dd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -149,6 +149,7 @@ Connection strings for each service in development are in each host's `appsettin - **Intra-domain dependencies are real; inter-domain dependencies are always mocked.** Own database, cache, and outbox are started and seeded in `[OneTimeSetUp]`. Cross-domain HTTP calls and direct broker publishes are replaced with `MockHttpClientFactory` / `UseExpectedAzureServiceBusPublisher()`. - Outbox assertion helpers are database-specific: `UseExpectedPostgresOutboxPublisher()` for Products; `UseExpectedSqlServerOutboxPublisher()` for Shopping. Do not use the SQL Server helper in Products tests. - Mock downstream HTTP calls; do not assume live APIs. +- **Outbox test-coverage split — don't re-flag as a gap without checking both places**: `tests\CoreEx.Database.SqlServer.Test.Unit` / `tests\CoreEx.Database.Postgres.Test.Unit` cover library-level logic only (parameter extensions, error-code mapping, `DatabaseRecord`/`DatabaseCommand` behavior) against a live database via DbEx migrations in the sibling `*.Test.Console` project — they do **not** exercise `SqlServerOutboxPublisher`/`SqlServerOutboxRelay` or `PostgresOutboxPublisher`/`PostgresOutboxRelay` end-to-end. That coverage lives in `samples\tests\Contoso.Products.Test.Relay` (Postgres) and `samples\tests\Contoso.Shopping.Test.Relay` (SQL Server): both spin up the real relay host (`WithApiTester<...Relay.Program>`), publish real events via the outbox, let the live `*OutboxRelayHostedService` poll and relay them, and assert what actually lands on the Service Bus emulator (`Test.GetAndClearAzureServiceBusAsync`). Keep the two relay test projects in parity — a fix or new scenario added to one should be mirrored in the other. ### House Rules - Code comments end with a period/full stop. diff --git a/.github/instructions/coreex-tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md index e3327ab2..7ceb0324 100644 --- a/.github/instructions/coreex-tooling.instructions.md +++ b/.github/instructions/coreex-tooling.instructions.md @@ -367,7 +367,7 @@ dotnet run -- script outbox # transactional outbox table(s) **Naming convention** (what `Script` produces, and what any hand-named file must match): `yyyyMMdd-HHmmss-.{sql|pgsql}`. - The leading segment is the **current UTC date *and* time** (`yyyyMMdd-HHmmss`) at the moment of creation — **not** a placeholder date (e.g. `20250101`) and **not** a per-day incrementing index (e.g. `000001`). The time component provides natural ordering and uniqueness without tracking indices. -- The entire filename is **kebab-lower-case** — all lowercase, words separated by hyphens (e.g. `20260603-142530-create-bar-employee.sql`, never `...-create-Bar-Employee.sql`). +- The entire filename is **kebab-lower-case** — all lowercase, words separated by hyphens (e.g. `20260603-142530-create-bar-employee.sql` for SQL Server, `20260603-142530-create-bar-employee.pgsql` for PostgreSQL — never `...-create-Bar-Employee.sql`). The extension follows the provider rule above; this section is only about the casing of the name itself. > **Do not author a schema-create script.** The `coreex` template already ships the default schema-create migration, so the schema exists from the first `Migrate`. Never emit a `create--schema` script unless the user **explicitly** asks for an additional schema. diff --git a/CoreEx.Samples.Build.slnf b/CoreEx.Samples.Build.slnf index 440502d3..63dda375 100644 --- a/CoreEx.Samples.Build.slnf +++ b/CoreEx.Samples.Build.slnf @@ -39,6 +39,7 @@ "samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj", "samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj", "samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj", + "samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj", "samples/tests/Contoso.Shopping.Test.Subscribe/Contoso.Shopping.Test.Subscribe.csproj", "samples/tests/Contoso.Shopping.Test.Unit/Contoso.Shopping.Test.Unit.csproj" ] diff --git a/CoreEx.Samples.Test.slnf b/CoreEx.Samples.Test.slnf index f2cef830..80eb6bd0 100644 --- a/CoreEx.Samples.Test.slnf +++ b/CoreEx.Samples.Test.slnf @@ -9,6 +9,7 @@ "samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj", "samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj", "samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj", + "samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj", "samples/tests/Contoso.Shopping.Test.Subscribe/Contoso.Shopping.Test.Subscribe.csproj", "samples/tests/Contoso.Shopping.Test.Unit/Contoso.Shopping.Test.Unit.csproj" ] diff --git a/CoreEx.sln b/CoreEx.sln index 19dffe0e..4d6f8247 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.8.11904.113 insiders +VisualStudioVersion = 18.8.11904.113 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{21B44B34-1D47-1312-99D7-9BFB05A71085}" EndProject @@ -246,6 +246,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.GraphQL", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.GraphQL.Test.Unit", "tests\CoreEx.Data.GraphQL.Test.Unit\CoreEx.Data.GraphQL.Test.Unit.csproj", "{831702B9-80AE-47D9-A0D5-012E9241A298}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Test.Relay", "samples\tests\Contoso.Shopping.Test.Relay\Contoso.Shopping.Test.Relay.csproj", "{F0782889-6889-4DF1-B025-06A92C0C4E11}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1156,6 +1158,18 @@ Global {831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x64.Build.0 = Release|Any CPU {831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x86.ActiveCfg = Release|Any CPU {831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x86.Build.0 = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x64.Build.0 = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x86.ActiveCfg = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x86.Build.0 = Debug|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|Any CPU.Build.0 = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x64.ActiveCfg = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x64.Build.0 = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.ActiveCfg = Release|Any CPU + {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1215,6 +1229,7 @@ Global {175C2EF4-9EBF-1E78-03BD-A1B569B79218} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {2F4DF929-C4B2-4A11-E2A7-13E4EC355269} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {60D956AA-3030-FB48-423C-F561A8157E30} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} + {F0782889-6889-4DF1-B025-06A92C0C4E11} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} = {A8732A47-07D4-8D47-C5B6-F97BD3E38958} {88D83B9E-144B-54B9-421D-13C133018F24} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} {D1281655-C259-3F9B-6488-B2410D6DF57F} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} diff --git a/CoreEx.slnx b/CoreEx.slnx index ea84749c..d5278928 100644 --- a/CoreEx.slnx +++ b/CoreEx.slnx @@ -85,6 +85,7 @@ + diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs index 9a838f82..ccd71c9b 100644 --- a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs @@ -168,7 +168,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.Price).HasColumnName("price").HasColumnType("NUMERIC(18, 2)"); e.Property(p => p.IsInactive).HasColumnName("is_inactive").HasColumnType("BOOLEAN"); e.Property(p => p.IsNonStocked).HasColumnName("is_non_stocked").HasColumnType("BOOLEAN"); - e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter?>.Default); + e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter?>.Default, TypeToJsonStringEfComparer?>.Default); e.Property(p => p.CreatedBy).HasColumnName("created_by").HasColumnType("CHARACTER VARYING(250)"); e.Property(p => p.CreatedOn).HasColumnName("created_on").HasColumnType("TIMESTAMP WITH TIME ZONE"); e.Property(p => p.UpdatedBy).HasColumnName("updated_by").HasColumnType("CHARACTER VARYING(250)"); diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs index 3c1560f6..07b13bcb 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -84,7 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)"); e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)"); e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)"); - e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter.Default); + e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter.Default, TypeToJsonStringEfComparer.Default); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); diff --git a/samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj b/samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj new file mode 100644 index 00000000..8d1bd26a --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/samples/tests/Contoso.Shopping.Test.Relay/GlobalUsing.cs b/samples/tests/Contoso.Shopping.Test.Relay/GlobalUsing.cs new file mode 100644 index 00000000..5390c0bb --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/GlobalUsing.cs @@ -0,0 +1,13 @@ +global using Contoso.Shopping.Infrastructure.Repositories; +global using CoreEx.Azure.Messaging.ServiceBus; +global using CoreEx.Events; +global using CoreEx.UnitTesting; +global using AwesomeAssertions; +global using Microsoft.Extensions.DependencyInjection; +global using NUnit.Framework; +global using System.Net; +global using UnitTestEx; +global using UnitTestEx.Expectations; +global using DbMigration = Contoso.Shopping.Database.Program; +global using ExecutionContext = CoreEx.ExecutionContext; +global using TestData = Contoso.Shopping.Test.Common.TestData; diff --git a/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.Health.cs b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.Health.cs new file mode 100644 index 00000000..3e783bfd --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.Health.cs @@ -0,0 +1,41 @@ +namespace Contoso.Shopping.Test.Relay; + +public partial class OtherTests +{ + [TestCase("/health/live")] + [TestCase("/health/startup")] + [TestCase("/health/ready")] + public void Health(string path) + { + Test.Http() + .Run(HttpMethod.Get, path) + .Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable); + } + + [TestCase("/health/live/detailed", true)] + [TestCase("/health/startup/detailed", false)] + [TestCase("/health/ready/detailed", false)] + public void Health_Detailed(string path, bool minimal) + { + string[] requiredServices = + [ + "sqlServer", + "sqlserver-outbox-relay-00", + "sqlserver-outbox-relay-01", + "sqlserver-outbox-relay-02", + "sqlserver-outbox-relay-03" + ]; + + var r = Test.Http() + .Run(HttpMethod.Get, path) + .AssertContentTypeJson(); + + r.Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable); + + var json = r.GetContent(); + if (minimal) + json.Should().NotContainAny(requiredServices); + else + json.Should().ContainAll(requiredServices); + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.HostedServices.cs b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.HostedServices.cs new file mode 100644 index 00000000..0ede25e5 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.HostedServices.cs @@ -0,0 +1,36 @@ +namespace Contoso.Shopping.Test.Relay; + +public partial class OtherTests +{ + [Test] + public void HostedService_Pause_And_Resume() + { + var s = Test.Http() + .Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status") + .Value; + + s.Should().BeOneOf("Running", "Sleeping"); + + Test.Http() + .Run(HttpMethod.Post, "/hosted-services/sqlserver-outbox-relay-03/pause") + .Response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + s = Test.Delay(TimeSpan.FromSeconds(1)) + .Http() + .Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status") + .Value; + + s.Should().Be("Paused"); + + Test.Http() + .Run(HttpMethod.Post, "/hosted-services/sqlserver-outbox-relay-03/resume") + .Response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + s = Test.Delay(TimeSpan.FromSeconds(1)) + .Http() + .Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status") + .Value; + + s.Should().BeOneOf("Running", "Sleeping"); + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.cs b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.cs new file mode 100644 index 00000000..d481fe51 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/OtherTests.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Test.Relay; + +public partial class OtherTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(["no-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/RelayTests.cs b/samples/tests/Contoso.Shopping.Test.Relay/RelayTests.cs new file mode 100644 index 00000000..dbb9a7cd --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/RelayTests.cs @@ -0,0 +1,46 @@ +using CoreEx.Database.SqlServer.Outbox; + +namespace Contoso.Shopping.Test.Relay; + +public class RelayTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(["no-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "shopping")); + } + + [Test] + public void Outbox_Relay() + { + // Arrange the two events to publish and relay. + var ce1 = Test.CreateCloudEventFromJsonResource("BasketCreatedCloudEvent.json"); + var ce2 = Test.CreateCloudEventFromJsonResource("BasketUpdatedCloudEvent.json"); + + // Publish two events to the outbox. + Test.ScopedType(test => + { + test.Run(async _ => + { + // Publish two events to the outbox. + var pub = ActivatorUtilities.GetServiceOrCreateInstance(test.Services); + pub.Add("contoso", [ce1, ce2]); + await pub.PublishAsync(); + + // Hosted-service(s) are currently running and should relay to Azure Service Bus; we just need to give it a few seconds to do so. + for (int i = 0; i < 5; i++) + await Task.Delay(TimeSpan.FromSeconds(1)); + + // Receive the events from Azure Service Bus and assert. + var list = await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "shopping")); + + list.Should().NotBeNull().And.HaveCount(2); + var ce1Msg = list.Should().ContainSingle(x => x.MessageId == ce1.Id).Subject; + var ce2Msg = list.Should().ContainSingle(x => x.MessageId == ce2.Id).Subject; + ObjectComparer.AssertJson(ce1.EncodeToJsonElement().ToString(), ce1Msg.Body.ToString()); + ObjectComparer.AssertJson(ce2.EncodeToJsonElement().ToString(), ce2Msg.Body.ToString()); + }).AssertSuccess(); + }); + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketCreatedCloudEvent.json b/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketCreatedCloudEvent.json new file mode 100644 index 00000000..74083b8d --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketCreatedCloudEvent.json @@ -0,0 +1,23 @@ +{ + "specversion": "1.0", + "id": "019c72b9-e8e4-73aa-83fc-c5847cd4c218", + "type": "contoso.shopping.basket.created.v1", + "source": "urn:contoso:shopping", + "subject": "019c72b9-e8d8-7d33-821b-3d18af870885", + "time": "2026-02-18T21:48:32.3532188Z", + "partitionkey": "019c72b9-e8d8-7d33-821b-3d18af870885", + "authtype": "user", + "authid": "DOMAIN-CORP\\eric.sibly", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "traceparent": "00-7de3f6abfabf606dd289d93e6d118457-cc3d1971a8b15a05-01", + "data": { + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-18T21:48:32.3532188+00:00" + }, + "id": "019c72b9-e8d8-7d33-821b-3d18af870885", + "customerId": "019c72b9-e8d8-7d33-821b-3d18af870886", + "statusCode": "ACTIVE" + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketUpdatedCloudEvent.json b/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketUpdatedCloudEvent.json new file mode 100644 index 00000000..4b974ac9 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/Resources/BasketUpdatedCloudEvent.json @@ -0,0 +1,25 @@ +{ + "specversion": "1.0", + "id": "019c72b9-e9ab-7a00-97e9-cdf97f2f6025", + "type": "contoso.shopping.basket.updated.v1", + "source": "urn:contoso:shopping", + "subject": "019c72b9-e8d8-7d33-821b-3d18af870885", + "time": "2026-02-18T21:48:32.5446661Z", + "partitionkey": "019c72b9-e8d8-7d33-821b-3d18af870885", + "authtype": "user", + "authid": "DOMAIN-CORP\\eric.sibly", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "traceparent": "00-1bd9e9901c03640e349802f6ce9df85f-e49a3299dd102aab-01", + "data": { + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-18T21:48:32.3532188+00:00", + "updatedBy": "DOMAIN-CORP\\eric.sibly", + "updatedOn": "2026-02-18T21:48:32.5446661+00:00" + }, + "id": "019c72b9-e8d8-7d33-821b-3d18af870885", + "customerId": "019c72b9-e8d8-7d33-821b-3d18af870886", + "statusCode": "ACTIVE" + } +} diff --git a/samples/tests/Contoso.Shopping.Test.Relay/appsettings.unittest.json b/samples/tests/Contoso.Shopping.Test.Relay/appsettings.unittest.json new file mode 100644 index 00000000..82eeea90 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Relay/appsettings.unittest.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + }, + "CoreEx": { + "Host": { + "Services": { + "Interval": "00:00:01" + } + } + }, + "Aspire": { + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://127.0.0.1;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "ClientOptions": { + "RetryOptions": { + "MaxRetries": 0, + "Delay": "00:00:01", + "MaxDelay": "00:00:01", + "Mode": "Fixed", + "TryTimeout": "00:00:01" + } + } + } + } + } + } +} diff --git a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxPublisher.cs b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxPublisher.cs index c5268ecb..9ac290d4 100644 --- a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxPublisher.cs +++ b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxPublisher.cs @@ -21,12 +21,17 @@ public class PostgresOutboxPublisher : DatabaseOutboxPublisherBaseThe optional . /// The optional . public PostgresOutboxPublisher(PostgresDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) - : base(database, destinationProvider, formatter, logger) + : base(database, destinationProvider, formatter, logger) => SetStatementByConvention(); + + /// + /// Sets the by convention, based on the (converted to snake_case, if available) and the function name of fn_outbox_enqueue. + /// + /// The optional schema name; defaults to the converted to snake_case. + public void SetStatementByConvention(string? schema = null) { - // Attempt to automatically set the statement by convention, if possible. - var schema = ExecutionContext.GetService()?.DomainName; + schema ??= SentenceCase.ToSnakeCase(ExecutionContext.GetService()?.DomainName); if (schema is not null) - Statement = SqlStatement.FromText($"SELECT \"{SentenceCase.ToSnakeCase(schema)}\".\"fn_outbox_enqueue\""); + Statement = SqlStatement.FromText($"SELECT \"{schema}\".\"fn_outbox_enqueue\""); } /// @@ -87,4 +92,4 @@ private static void AddParameter(StringBuilder sb, DatabaseParameterCollection d dpc.AddParameter($"{name}_{index}", value, dbType); } -} \ No newline at end of file +} diff --git a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs index 91a78988..76e705f5 100644 --- a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs +++ b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs @@ -11,7 +11,7 @@ public class PostgresOutboxRelay(PostgresDatabase database, IEventPublisher even { /// /// - /// The (converted to snake_case) is used to qualify the database function names. The by-convention names used are as follows: + /// The (defaults to the converted to snake_case) is used to qualify the database function names. The by-convention names used are as follows: /// /// = 'SELECT * FROM "schema"."fn_outbox_batch_claim"(...' /// = 'SELECT "schema"."fn_outbox_batch_complete"(...' @@ -20,11 +20,9 @@ public class PostgresOutboxRelay(PostgresDatabase database, IEventPublisher even /// The parameters are positional and must match the expected order in the database functions. public override void SetStatementsByConvention(string? schema = null) { - schema ??= ExecutionContext.GetService()?.DomainName; + schema ??= SentenceCase.ToSnakeCase(ExecutionContext.GetService()?.DomainName); if (schema is not null) { - schema = SentenceCase.ToSnakeCase(schema); - ClaimBatchStatement = SqlStatement.FromText($"SELECT * FROM \"{schema}\".\"fn_outbox_batch_claim\"(@{Database.NamedColumns.PartitionIdName}, @{Database.NamedColumns.OutboxBatchSizeName}, @{Database.NamedColumns.OutboxLeaseIdName}, @{Database.NamedColumns.OutboxLeaseDurationName}, @{Database.NamedColumns.TenantIdName})"); CompleteBatchStatement = SqlStatement.FromText($"SELECT \"{schema}\".\"fn_outbox_batch_complete\"(@{Database.NamedColumns.OutboxLeaseIdName}, @{Database.NamedColumns.OutboxDequeuedUtcName})"); CancelBatchStatement = SqlStatement.FromText($"SELECT \"{schema}\".\"fn_outbox_batch_cancel\"(@{Database.NamedColumns.OutboxLeaseIdName}, @{Database.NamedColumns.OutboxBackoffDurationName})"); @@ -52,7 +50,7 @@ protected override bool IsTransientException(Exception exception) /// protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) { - await base.CompleteBatchAsync(args, leaseId, cancellationToken); + await base.CompleteBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); if (EventPublisher.IsEmpty) return; @@ -62,4 +60,4 @@ protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, G PostgresMetrics.OutboxRelayOldestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[0].Event.Time ?? default)).TotalMilliseconds); PostgresMetrics.OutboxRelayNewestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[^1].Event.Time ?? default)).TotalMilliseconds); } -} \ No newline at end of file +} diff --git a/src/CoreEx.Database.Postgres/PostgresDatabase.cs b/src/CoreEx.Database.Postgres/PostgresDatabase.cs index 88bedda2..4d127b87 100644 --- a/src/CoreEx.Database.Postgres/PostgresDatabase.cs +++ b/src/CoreEx.Database.Postgres/PostgresDatabase.cs @@ -21,7 +21,7 @@ namespace CoreEx.Database.Postgres; /// The . /// The optional . /// The optional . -public partial class PostgresDatabase(NpgsqlDataSource dataSource, JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null) : Database(dataSource.CreateConnection(), PostgresInvoker.Default, PostgresDatabaseColumns.Default, jsonSerializerOptions, logger) +public partial class PostgresDatabase(NpgsqlDataSource dataSource, JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null) : Database(CreateConnection(dataSource), PostgresInvoker.Default, PostgresDatabaseColumns.Default, jsonSerializerOptions, logger) { /// /// Gets the default . @@ -29,6 +29,11 @@ public partial class PostgresDatabase(NpgsqlDataSource dataSource, JsonSerialize /// See . public static string[] DefaultDuplicateErrorNumbers { get; } = ["23505"]; + /// + /// Creates the from the specified . + /// + private static NpgsqlConnection CreateConnection(NpgsqlDataSource dataSource) => dataSource.ThrowIfNull().CreateConnection(); + /// public override ISourceConverter RowVersionConverter => EncodedStringToUInt32Converter.Default; diff --git a/src/CoreEx.Database.Postgres/README.md b/src/CoreEx.Database.Postgres/README.md index c2ccd9f2..1876054f 100644 --- a/src/CoreEx.Database.Postgres/README.md +++ b/src/CoreEx.Database.Postgres/README.md @@ -17,8 +17,8 @@ The outbox sub-namespace provides ready-to-use `PostgresOutboxPublisher`, `Postg - 🔁 **PostgresUnitOfWork**: `IDatabaseUnitOfWork` implementation wrapping `TransactionAsync` with `PostgresUnitOfWorkInvoker`; optionally accepts an `IEventPublisher` outbox for transactional event enqueuing. - 📤 **Outbox relay**: `PostgresOutboxPublisher` (writes to outbox table), `PostgresOutboxRelay` (polls and publishes), and `PostgresOutboxRelayHostedService` (timer-driven hosted service) — all PostgreSQL-specific subclasses of the base `CoreEx.Database.Outbox` types. - 📊 **Outbox metrics**: `PostgresMetrics` exposes .NET `Meter` instruments: `postgres.outbox.enqueue` (counter), `postgres.outbox.relay.batch.size` (counter), `postgres.outbox.batch.oldest_lag` and `postgres.outbox.batch.newest_lag` (histograms in ms). -- 📡 **OpenTelemetry**: `CoreExPostgresExtensions.AddCoreExPostgresOpenTelemetry` wires `PostgresInvoker` activity sources and the outbox meter into the OTEL tracer and meter providers. -- ⚙️ **DI registration**: `AddPostgresDatabase(services, configure?)` registers `PostgresDatabase` as a scoped service; `AddPostgresUnitOfWork(services, configure?)` registers `PostgresUnitOfWork`. +- 📡 **OpenTelemetry**: `CoreExPostgresExtensions.WithCoreExPostgresTelemetry` wires `PostgresInvoker` activity sources and the outbox meter into the OTEL tracer and meter providers. +- ⚙️ **DI registration**: `AddPostgresDatabase(services, configure?)` registers `PostgresDatabase` as a scoped service; `AddPostgresUnitOfWork(services, addAsIUnitOfWork = true)` registers `PostgresUnitOfWork`. ## Key types diff --git a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs index 48159c64..c1ee6c53 100644 --- a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs @@ -20,10 +20,15 @@ public class SqlServerOutboxPublisher : DatabaseOutboxPublisherBaseThe optional . /// The optional . public SqlServerOutboxPublisher(SqlServerDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) - : base(database, destinationProvider, formatter, logger) + : base(database, destinationProvider, formatter, logger) => SetStatementByConvention(); + + /// + /// Sets the by convention, based on the (if available) and the stored procedure name of spOutboxEnqueue. + /// + /// The optional schema name. + public void SetStatementByConvention(string? schema = null) { - // Attempt to automatically set the statement by convention, if possible. - var schema = ExecutionContext.GetService()?.DomainName; + schema ??= ExecutionContext.GetService()?.DomainName; if (schema is not null) Statement = SqlStatement.StoredProcedure($"[{schema}].[spOutboxEnqueue]"); } @@ -85,4 +90,4 @@ private static void AddParameter(StringBuilder sb, DatabaseParameterCollection d dpc.AddParameter($"{name}_{index}", value); } -} \ No newline at end of file +} diff --git a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs index 510c534a..7726c10e 100644 --- a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs @@ -11,7 +11,7 @@ public class SqlServerOutboxRelay(SqlServerDatabase database, IEventPublisher ev { /// /// - /// The is used to qualify the stored procedure names. The by-convention names used are as follows: + /// The (defaults to the ) is used to qualify the stored procedure names. The by-convention names used are as follows: /// /// = '[schema].[spOutboxBatchClaim]' /// = '[schema].[spOutboxBatchComplete]' @@ -45,7 +45,7 @@ protected override bool IsTransientException(Exception exception) /// protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) { - await base.CompleteBatchAsync(args, leaseId, cancellationToken); + await base.CompleteBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); if (EventPublisher.IsEmpty) return; @@ -55,4 +55,4 @@ protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, G SqlServerMetrics.OutboxRelayOldestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[0].Event.Time ?? default)).TotalMilliseconds); SqlServerMetrics.OutboxRelayNewestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[^1].Event.Time ?? default)).TotalMilliseconds); } -} \ No newline at end of file +} diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs index dbaf0215..cbb1f4bb 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs @@ -22,7 +22,7 @@ public Task SetSqlSessionContextAsync(string? username, DateTimeOffset? timestam { return Invoker.InvokeAsync(this, DbArgs, async (_, _, cancellationToken) => { - var r = await Statement(SessionContextStatement) + await Statement(SessionContextStatement) .Param(NamedColumns.SessionContextUsernameName, username ?? AuthenticationUser.EnvironmentUser.UserName) .Param(NamedColumns.SessionContextTimestampName, timestamp ?? Runtime.UtcNow) .ParamWith(tenantId, NamedColumns.SessionContextTenantIdName) diff --git a/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs index 5c045566..29095f84 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs @@ -67,7 +67,8 @@ public static TSelf ParamWhen(this IDatabaseParameters paramete /// Adds a named parameter when invoked a non-default value. /// /// The owning . - /// The parameter . + /// The with . + /// The parameter . /// The . /// The value with which to verify is non-default. /// The parameter name. @@ -75,8 +76,8 @@ public static TSelf ParamWhen(this IDatabaseParameters paramete /// The parameter . /// The (default to ). /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, string name, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals((T)with, default!), name, value, sqlDbType, direction); + public static TSelf ParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, value, sqlDbType, direction); /// /// Adds a named parameter when invoked a non-default value. diff --git a/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs index ddf04524..e4d53cda 100644 --- a/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs +++ b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs @@ -135,8 +135,8 @@ async Task RollbackAsync(Exception exception) var outboxEnqueued = 0; if (unitOfWork.AreEventsSupported && !unitOfWork.Events.IsEmpty) { + outboxEnqueued = unitOfWork.Outbox!.Count; await unitOfWork.Outbox!.PublishAsync(cancellationToken).ConfigureAwait(false); - outboxEnqueued = unitOfWork.Outbox.Count; } // Commit the work and outbox. diff --git a/src/CoreEx.Database/Database.cs b/src/CoreEx.Database/Database.cs index adeaa9c6..5661daa3 100644 --- a/src/CoreEx.Database/Database.cs +++ b/src/CoreEx.Database/Database.cs @@ -176,6 +176,8 @@ public void Dispose() /// Releases the unmanaged resources used by the and optionally releases the managed resources. /// /// to release both managed and unmanaged resources; to release only unmanaged resources. + /// The injected is intentionally not disposed here; it is owned by whatever registered it in the dependency injection container (e.g. an Aspire client integration), + /// and may be shared with other collaborators within the same scope (e.g. an EfDb/DbContext participating in the same transaction). protected virtual void Dispose(bool disposing) { if (disposing && !_disposed) diff --git a/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs b/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs index ea5bfc85..3a8caaa9 100644 --- a/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs +++ b/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs @@ -54,7 +54,9 @@ public async Task SelectFirstAsync(IDatabaseMapper mapper, Cancellation private async Task SelectSingleFirstInternalAsync(IDatabaseMapper mapper, bool throwWhereMulti, string memberName, CancellationToken cancellationToken) { var coll = new List(); - await SelectInternalAsync(coll, mapper, throwWhereMulti, false, 2, memberName, cancellationToken).ConfigureAwait(false); + + // Where not required to detect/throw on multiple rows (i.e. "first" semantics), stop immediately after mapping the first row to avoid the cost of reading/mapping a redundant second row. + await SelectInternalAsync(coll, mapper, throwWhereMulti, !throwWhereMulti, 2, memberName, cancellationToken).ConfigureAwait(false); return coll.Count == 0 ? default! : coll[0]; } diff --git a/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs b/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs index cc715dc0..49855bbf 100644 --- a/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs +++ b/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs @@ -64,9 +64,9 @@ await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationTo } index++; - } while (dr.NextResult()); + } while (await dr.NextResultAsync(cancellationToken).ConfigureAwait(false)); - if (index < multiSetList.Count && !multiSetList[index].StopOnNull) + if (index < multiSetList.Count && (multiSetList[index] is null || !multiSetList[index].StopOnNull)) throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} has returned less ({index}) record sets than expected ({multiSetList.Count})."); }, cancellationToken, memberName).ConfigureAwait(false); } diff --git a/src/CoreEx.Database/DatabaseCommand.cs b/src/CoreEx.Database/DatabaseCommand.cs index 5a4c64a4..abce9b40 100644 --- a/src/CoreEx.Database/DatabaseCommand.cs +++ b/src/CoreEx.Database/DatabaseCommand.cs @@ -32,6 +32,9 @@ public abstract partial class DatabaseCommand(IDatabase db, SqlStatement stateme /// The . private async Task CreateCommandAsync(CancellationToken cancellationToken) { + if (Statement.IsIndeterminate) + throw new InvalidOperationException($"Cannot execute a command where the {nameof(Statement)} is {nameof(SqlStatement.IsIndeterminate)}; the {nameof(SqlStatement)} must be set to a valid command."); + var conn = await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false); var cmd = conn.CreateCommand(); diff --git a/src/CoreEx.Database/DatabaseParameterCollection.cs b/src/CoreEx.Database/DatabaseParameterCollection.cs index f29b9a54..2d6c6c91 100644 --- a/src/CoreEx.Database/DatabaseParameterCollection.cs +++ b/src/CoreEx.Database/DatabaseParameterCollection.cs @@ -1,5 +1,3 @@ -using CoreEx.Mapping.Converters; - namespace CoreEx.Database; /// diff --git a/src/CoreEx.Database/DatabaseRecord.cs b/src/CoreEx.Database/DatabaseRecord.cs index e88dd30e..a2cc5587 100644 --- a/src/CoreEx.Database/DatabaseRecord.cs +++ b/src/CoreEx.Database/DatabaseRecord.cs @@ -1,5 +1,3 @@ -using CoreEx.Mapping.Converters; - namespace CoreEx.Database; /// @@ -156,7 +154,7 @@ public bool IsDBNull(string columnName, out int ordinal) { var i = DataReader.GetOrdinal(!string.IsNullOrEmpty(columnName) ? columnName : Database.NamedColumns.RowVersionName); var v = DataReader.GetValue(i); - return Database.RowVersionConverter.ConvertToSource(v) ?? null; + return Database.RowVersionConverter.ConvertToSource(v); } /// diff --git a/src/CoreEx.Database/Extended/DatabaseWildcard.cs b/src/CoreEx.Database/Extended/DatabaseWildcard.cs index 8f2ae264..9d7e2cdd 100644 --- a/src/CoreEx.Database/Extended/DatabaseWildcard.cs +++ b/src/CoreEx.Database/Extended/DatabaseWildcard.cs @@ -88,8 +88,7 @@ public DatabaseWildcard(Wildcard? wildcard = null, char multiWildcard = MultiWil /// The SQL LIKE wildcard. public string? Replace(string? text) { - var wc = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; - var wr = wc.Parse(text).ThrowOnError(); + var wr = Wildcard.Parse(text).ThrowOnError(); if (wr.Selection.HasFlag(WildcardSelection.None) || wr.Selection.HasFlag(WildcardSelection.Single) && wr.Selection.HasFlag(WildcardSelection.MultiWildcard)) return new string(MultiWildcard, 1); diff --git a/src/CoreEx.Database/Extended/MultiSetCollArgs.cs b/src/CoreEx.Database/Extended/MultiSetCollArgs.cs index 59d42e13..7372969d 100644 --- a/src/CoreEx.Database/Extended/MultiSetCollArgs.cs +++ b/src/CoreEx.Database/Extended/MultiSetCollArgs.cs @@ -13,8 +13,8 @@ public abstract class MultiSetCollArgs : IMultiSetArgs /// Indicates whether to stop further query result set processing where the current set has resulted in a (i.e. no records). public MultiSetCollArgs(int minimumRows = 0, int? maximumRows = null, bool stopOnNull = false) { - if (maximumRows.HasValue && minimumRows <= maximumRows.Value) - throw new ArgumentException("Max Rows is less than Min Rows.", nameof(maximumRows)); + if (maximumRows.HasValue && minimumRows > maximumRows.Value) + throw new ArgumentException("Min Rows is greater than Max Rows.", nameof(maximumRows)); MinimumRows = minimumRows; MaximumRows = maximumRows; diff --git a/src/CoreEx.Database/GlobalUsing.cs b/src/CoreEx.Database/GlobalUsing.cs index a5fc3688..7986ce4e 100644 --- a/src/CoreEx.Database/GlobalUsing.cs +++ b/src/CoreEx.Database/GlobalUsing.cs @@ -10,6 +10,7 @@ global using CoreEx.Hosting; global using CoreEx.Invokers; global using CoreEx.Json; +global using CoreEx.Mapping.Converters; global using CoreEx.Mapping.Converters.Abstractions; global using CoreEx.RefData.Abstractions; global using CoreEx.Results; diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs index 412b8363..460ab223 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs @@ -105,7 +105,7 @@ public async Task RelayAsync(DatabaseOutboxRelayArgs args, CancellationTok // Perform the relay for the partition using a new timer-based cancellation token that is based on the lease duration to ensure it completes within the lease window to minimize the risk of the batch // being cancelled due to exceeding the lease duration before the relay operation has had a chance to complete. - var leaseCancellationTokenSource = new CancellationTokenSource(args.LeaseDuration); + using var leaseCancellationTokenSource = new CancellationTokenSource(args.LeaseDuration); try { var relay = await RelayAsync(args, partitionId, leaseCancellationTokenSource.Token).ConfigureAwait(false); @@ -207,13 +207,21 @@ await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => } catch (Exception ex) { - // Cancel the batch. - await CancelBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); + // Cancel the batch; guard against a secondary failure masking the original exception. + try + { + await CancelBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); - if (Logger?.IsEnabled(LogLevel.Debug) is true) - Logger.LogDebug("Outbox batch was cancelled due to error: {Error}", ex.Message); + if (Logger?.IsEnabled(LogLevel.Debug) is true) + Logger.LogDebug("Outbox batch was cancelled due to error: {Error}", ex.Message); + } + catch (Exception cancelEx) + { + if (Logger?.IsEnabled(LogLevel.Error) is true) + Logger.LogError(cancelEx, "Failed to cancel the outbox batch following the original error: {Error}", ex.Message); + } - // Keep bubbling the exception. + // Keep bubbling the original exception. throw; } finally @@ -239,8 +247,7 @@ protected virtual async Task> ClaimNextBatchAsync(Databas // Add 10% more to ensure the lease duration is slightly longer than the cancellation token used for the relay operation to minimize the risk of the batch being cancelled due to exceeding the lease duration // before the relay operation has had a chance to complete. - var leaseDurationSeconds = ConvertDurationToSeconds(args.LeaseDuration); - leaseDurationSeconds += Math.Min(1, (int)Math.Round(leaseDurationSeconds * 0.1, MidpointRounding.AwayFromZero)); + var leaseDurationSeconds = OutboxDuration.ToLeaseSecondsWithBuffer(args.LeaseDuration); try { @@ -263,6 +270,9 @@ await Database.Statement(ClaimBatchStatement) { if (!IsTransientException(ex)) throw; + + if (Logger?.IsEnabled(LogLevel.Warning) is true) + Logger.LogWarning(ex, "Transient exception occurred whilst claiming an outbox batch for partition '{PartitionId}'; the claim will be retried on the next poll: {Error}", partitionId, ex.Message); } return events; @@ -289,7 +299,7 @@ protected virtual Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid lea protected virtual Task CancelBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) => Database.Statement(CancelBatchStatement) .Param(Database.NamedColumns.OutboxLeaseIdName, leaseId) - .Param(Database.NamedColumns.OutboxBackoffDurationName, ConvertDurationToSeconds(args.BackOffDuration)) + .Param(Database.NamedColumns.OutboxBackoffDurationName, OutboxDuration.ToSeconds(args.BackOffDuration)) .NonQueryAsync(cancellationToken); /// @@ -299,9 +309,4 @@ protected virtual Task CancelBatchAsync(DatabaseOutboxRelayArgs args, Guid lease /// where the exception is considered transient; otherwise, . /// For example, a timeout or deadlock exception that may occur during the claim of the batch and is expected to be transient in nature. protected virtual bool IsTransientException(Exception exception) => false; - - /// - /// Converts a duration time-span into a rounded number of seconds where the minimum allowed is one second. - /// - private static int ConvertDurationToSeconds(TimeSpan duration) => Math.Min((int)Math.Round(duration.TotalSeconds, MidpointRounding.AwayFromZero), 1); } \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs index aaab9052..38a6fa73 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs @@ -57,7 +57,7 @@ protected async override Task OnInitializeAsync(CancellationToken cancellationTo PartitionSize = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:PartitionSize", "CoreEx:Host:Services:OutboxRelay:PartitionSize", PartitionKey.DefaultPartitionSize, Configuration); PerWorkerPartitionCount = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:PerWorkerPartitionCount", "CoreEx:Host:Services:OutboxRelay:PerWorkerPartitionCount", 6, Configuration); - _partitionPicker = new PartitionPicker(PartitionKey.DefaultPartitionSize, PerWorkerPartitionCount); + _partitionPicker = new PartitionPicker(PartitionSize, PerWorkerPartitionCount); if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("{ServiceName} settings: BatchSize={BatchSize}, LeaseDuration={LeaseDuration}, BackOffDuration={BackOffDuration}, PartitionSize={PartitionSize}, PerWorkerPartitionCount={PerWorkerPartitionCount}", diff --git a/src/CoreEx.Database/Outbox/OutboxDuration.cs b/src/CoreEx.Database/Outbox/OutboxDuration.cs new file mode 100644 index 00000000..3fedd7fc --- /dev/null +++ b/src/CoreEx.Database/Outbox/OutboxDuration.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides to whole-second conversion capabilities for outbox lease/backoff durations. +/// +public static class OutboxDuration +{ + /// + /// Converts a duration time-span into a rounded number of seconds where the minimum allowed is one second. + /// + /// The duration. + /// The number of seconds; a minimum of one. + public static int ToSeconds(TimeSpan duration) => Math.Max((int)Math.Round(duration.TotalSeconds, MidpointRounding.AwayFromZero), 1); + + /// + /// Converts a lease duration time-span into a rounded number of seconds, with an additional buffer of 10% (minimum of one second) to minimize the risk of the batch being cancelled due to + /// exceeding the lease duration before the relay operation has had a chance to complete. + /// + /// The lease duration. + /// The buffered number of seconds. + public static int ToLeaseSecondsWithBuffer(TimeSpan leaseDuration) + { + var seconds = ToSeconds(leaseDuration); + return seconds + Math.Max(1, (int)Math.Round(seconds * 0.1, MidpointRounding.AwayFromZero)); + } +} diff --git a/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs index 4b89147e..d2afb316 100644 --- a/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs +++ b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs @@ -30,11 +30,11 @@ public partial class {{Domain}}DbContext e.HasKey({{#ifeq PrimaryKeyColumns.Count 1}}p => p.{{#each PrimaryKeyColumns}}{{Property}}{{/each}}{{else}}{{#each PrimaryKeyColumns}}"{{Property}}"{{#unless @last}}, {{/unless}}{{/each}}{{/ifeq}}); {{/ifne}} {{#each Columns}} - e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; + e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default, TypeToJsonStringEfComparer<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; {{/each}} {{/if}} {{#each StandardColumns}} - e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; + e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default, TypeToJsonStringEfComparer<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; {{/each}} {{#if HasColumnCreatedBy}} e.Property(p => p.CreatedBy).HasColumnName("{{ColumnCreatedBy.Name}}").HasColumnType("{{ColumnCreatedBy.DbColumn.SqlType2}}"); diff --git a/src/CoreEx.EntityFrameworkCore/Converters/README.md b/src/CoreEx.EntityFrameworkCore/Converters/README.md index daa5e814..e4a11db1 100644 --- a/src/CoreEx.EntityFrameworkCore/Converters/README.md +++ b/src/CoreEx.EntityFrameworkCore/Converters/README.md @@ -1,6 +1,6 @@ # CoreEx.EntityFrameworkCore.Converters -> Provides EF Core `ValueConverter` bridges that allow CoreEx `IConverter` implementations to be used directly in EF model configuration, including a built-in `JsonElement` ↔ `string` converter. +> Provides EF Core `ValueConverter` bridges that allow CoreEx `IConverter` implementations to be used directly in EF model configuration, including built-in `JsonElement` ↔ `string` and arbitrary-type ↔ JSON `string` converters with matching `ValueComparer` support. ## Overview @@ -17,6 +17,8 @@ EF Core's `ValueConverter` type is the standard extension poi | **[`ValueConverterBridge`](./ValueConverterBridgeT2.cs)** | EF Core `ValueConverter` that delegates to a CoreEx `IConverter`; use `ValueConverterBridge.Create(converter)` for concise construction. | | **[`ValueConverterBridge`](./ValueConverterBridge.cs)** | Static factory with two `Create` overloads — one accepting a typed `IConverter` and one accepting the non-generic `IConverter` base (validated at runtime). | | **[`JsonElementStringEfConverter`](./JsonElementStringEfConverter.cs)** | Pre-built `ValueConverterBridge` using `JsonElementStringConverter.Default`; exposes a `Default` singleton for direct use in model configuration. | +| **[`TypeToJsonStringEfConverter`](./TypeToJsonStringEfConverter.cs)** | Converts any `T` to/from a JSON `string` column using `TypeToJsonStringConverter.Default`; exposes a `Default` singleton. Used automatically by the code generator for non-string JSON columns. | +| **[`TypeToJsonStringEfComparer`](./TypeToJsonStringEfComparer.cs)** | `ValueComparer` companion to `TypeToJsonStringEfConverter`: compares, hashes, and snapshots values via JSON serialization so EF change tracking correctly detects mutations in collection and complex-type properties. Always pair with the converter via `.HasConversion(converter, comparer)`. | ## Usage @@ -28,6 +30,14 @@ modelBuilder.Entity() .HasConversion(JsonElementStringEfConverter.Default); ``` +Store a complex type or collection as a JSON column (comparer required to detect mutations): + +```csharp +modelBuilder.Entity() + .Property(p => p.Tags) + .HasConversion(TypeToJsonStringEfConverter?>.Default, TypeToJsonStringEfComparer?>.Default); +``` + Or wire any custom CoreEx converter: ```csharp diff --git a/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfComparer.cs b/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfComparer.cs new file mode 100644 index 00000000..1d5f7190 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfComparer.cs @@ -0,0 +1,16 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides a that compares by JSON serialization, for use alongside . +/// +/// The model type. +public sealed class TypeToJsonStringEfComparer() : ValueComparer( + (a, b) => JsonSerializer.Serialize(a, JsonDefaults.SerializerOptions) == JsonSerializer.Serialize(b, JsonDefaults.SerializerOptions), + v => JsonSerializer.Serialize(v, JsonDefaults.SerializerOptions).GetHashCode(), + v => JsonSerializer.Deserialize(JsonSerializer.Serialize(v, JsonDefaults.SerializerOptions), JsonDefaults.SerializerOptions)!) +{ + /// + /// Gets the default instance. + /// + public static TypeToJsonStringEfComparer Default { get; } = new(); +} diff --git a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs index 3470e5eb..05d151b9 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs @@ -15,8 +15,11 @@ public record class EfDbArgs : DatabaseArgsBase /// Indicates whether the performs a so that the model is not tracked. /// /// Defaults to ; in that there will be tracking. - /// The implementation performs a - /// internally which automatically attaches and tracks. + /// The implementation performs a + /// internally which automatically attaches and tracks. + /// Warning: detaches every entity currently tracked by the underlying — not just the model just retrieved. Because the is typically + /// scoped and may be shared across multiple repository calls within the same unit of work, enabling this on one model's will also silently detach unrelated entities tracked elsewhere in that same scope + /// (e.g. pending unsaved changes from another repository call in the same SaveChanges batch). Only enable this where the is known not to be shared for the duration of the operation. public bool ClearChangeTrackerAfterGet { get; init; } = false; /// diff --git a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs index ffec438a..0ecca936 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs @@ -39,7 +39,10 @@ public static partial class EfDbExtensions /// The mapping . /// The . public static async Task ToMappedItemsAsync(this IQueryable query, IMapper mapper, CancellationToken cancellationToken = default) where TSource : class where TColl : ICollection, new() where TItem : class - => await ToMappedItemsAsync(query, source => mapper.Map(source)!, cancellationToken).ConfigureAwait(false); + { + mapper.ThrowIfNull(); + return await ToMappedItemsAsync(query, source => mapper.Map(source)!, cancellationToken).ConfigureAwait(false); + } /// /// Creates a from a using the specified . @@ -73,7 +76,10 @@ public static async Task> ToMappedItemsAsync(this IQ /// The mapping . /// The . public static async Task> ToMappedItemsAsync(this IQueryable query, IMapper mapper, CancellationToken cancellationToken = default) where TSource : class where TItem : class - => await ToMappedItemsAsync(query, source => mapper.Map(source)!, cancellationToken).ConfigureAwait(false); + { + mapper.ThrowIfNull(); + return await ToMappedItemsAsync(query, source => mapper.Map(source)!, cancellationToken).ConfigureAwait(false); + } /// /// Creates a from an applying (including with where requested). @@ -143,5 +149,8 @@ public static async Task> ToMappedItemsResultAsyncThe indicates whether the query should be automatically executed using the before the /// is applied and . This is opt-in as not all LINQ implementations support the reuse of the query, or allow counthing where ordering has previously been applied. public static async Task> ToMappedItemsResultAsync(this IQueryable query, IMapper mapper, PagingArgs? paging = null, bool autoCount = true, CancellationToken cancellationToken = default) where TSource : class where TItem : class - => await query.ToMappedItemsResultAsync(source => mapper.Map(source)!, paging, autoCount, cancellationToken).ConfigureAwait(false); + { + mapper.ThrowIfNull(); + return await query.ToMappedItemsResultAsync(source => mapper.Map(source)!, paging, autoCount, cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs index a60ddb48..2fbbd9ea 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs @@ -57,38 +57,30 @@ private async Task> DeleteWithResultInternalAsync(EfDbArgs ar return cmr.Bind(); // Delete or logical delete as appropriate. - try + switch (Options.LogicalDeleteSupport) { - switch (Options.LogicalDeleteSupport) - { - // Physical delete. - case FeatureSupport.NotSupported: - EfDb.DbContext.Remove(model!); - break; + // Physical delete. + case FeatureSupport.NotSupported: + EfDb.DbContext.Remove(model!); + break; - // Logical delete (ambiguous exception). - case FeatureSupport.ReadOnly: - throw new InvalidOperationException($"The '{nameof(Options)}.{nameof(Options.LogicalDeleteSupport)}' is set to '{nameof(FeatureSupport.ReadOnly)}' which is ambiguous for a delete operation; the model must implement '{nameof(ILogicallyDeleted)}' not '{nameof(IReadOnlyLogicallyDeleted)}'."); + // Logical delete (ambiguous exception). + case FeatureSupport.ReadOnly: + throw new InvalidOperationException($"The '{nameof(Options)}.{nameof(Options.LogicalDeleteSupport)}' is set to '{nameof(FeatureSupport.ReadOnly)}' which is ambiguous for a delete operation; the model must implement '{nameof(ILogicallyDeleted)}' not '{nameof(IReadOnlyLogicallyDeleted)}'."); - // Logical delete (update). - case FeatureSupport.Mutable: - var ld = (ILogicallyDeleted)model!; - ld.IsDeleted = true; - Model.PrepareUpdate(model, EfDb.ExecutionContext); + // Logical delete (update). + case FeatureSupport.Mutable: + var ld = (ILogicallyDeleted)model!; + ld.IsDeleted = true; + Model.PrepareUpdate(model, EfDb.ExecutionContext); - EfDb.DbContext.Update(model!); - break; - } + EfDb.DbContext.Update(model!); + break; + } - if (args.SaveChanges) - await EfDb.DbContext.SaveChangesAsync(true, cancellationToken).ConfigureAwait(false); + if (args.SaveChanges) + await EfDb.DbContext.SaveChangesAsync(true, cancellationToken).ConfigureAwait(false); - return Result.Ok(DataResult.True); - } - catch (NotFoundException) - { - // A hopefully rare, but expected and OK behavior; swallowing is intended here. - return Result.Ok(DataResult.False); - } + return Result.Ok(DataResult.True); }, cancellationToken, memberName).ConfigureAwait(false); } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs index 96424e84..73df3660 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs @@ -11,7 +11,7 @@ public partial class EfDbModel public IQueryable Query(EfDbArgs? args = null) { args ??= Args; - return Options.ApplyFilters(args, args.QueryTracking ? EfDb.DbContext.Set() : EfDb.DbContext.Set().AsNoTracking()); + return Options.ApplyFilters(args, args.QueryTracking ? EfDb.DbContext.Set() : EfDb.DbContext.Set().AsNoTracking(), EfDb.ExecutionContext); } /// diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs index 48a78ff8..c41e6db7 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs @@ -79,6 +79,22 @@ private async Task>> UpdateWithResultInternalAsync(EfD // Where attached and is unchanged then exit as there is nothing to do. case Microsoft.EntityFrameworkCore.EntityState.Unchanged: return Result.Ok(new DataResult(model!, false)); + + // Where already tracked (e.g. a prior Get within the same DbContext, mutated in place), the in-memory ETag reflects + // only what this DbContext believes is current; it will not reveal a concurrent write made by another process/DbContext. + // Where the ETag is not also configured as an EF concurrency token (in which case SaveChanges will detect this natively), + // query the row's actual current value - without disturbing the tracked CurrentValues/pending changes - to detect it here. + default: + if (model is IReadOnlyETag trackedEtag && EfDb.DbContext.Model.FindEntityType(typeof(TModel))?.FindProperty(nameof(IReadOnlyETag.ETag))?.IsConcurrencyToken != true) + { + var dbValues = await EfDb.DbContext.Entry(model).GetDatabaseValuesAsync(cancellationToken).ConfigureAwait(false); + if (dbValues is null) + return Result.NotFoundError(); + + if (!ETag.TryCompare(trackedEtag.ETag, dbValues.GetValue(nameof(IReadOnlyETag.ETag)))) + return Result.ConcurrencyError(); + } + break; } // Prepare the model. diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs index c08a4df4..adbc1a79 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs @@ -50,8 +50,11 @@ internal EfDbModel(IEfDb efDb, EfDbModelOptions options) // Check valid tenant where multi-tenancy is being used. if (model is IReadOnlyTenantId tenant) { - model.ThrowWhen(_ => string.IsNullOrEmpty(tenant.TenantId), $"{nameof(ITenantId.TenantId)} must be specified."); - if (tenant.TenantId != ExecutionContext.Current.TenantId) + // TenantId is stamped automatically (see Model.PrepareCreate/PrepareUpdate) and is never caller-supplied; a null/empty value is an internal data-integrity/environment problem, not a bad request from the caller. + if (string.IsNullOrEmpty(tenant.TenantId)) + throw new InvalidOperationException($"The model's {nameof(ITenantId.TenantId)} is null or empty; {nameof(IReadOnlyTenantId)} requires tenant stamping to have occurred prior to this check."); + + if (tenant.TenantId != EfDb.ExecutionContext.TenantId) return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); } diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs index 5008deae..9468761f 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs @@ -9,6 +9,8 @@ public class EfDbModelOptions where TModel : class private Func _getKey = m => m is IEntityKey ek ? ek.EntityKey : throw new InvalidOperationException($"The model does not implement {nameof(IEntityKey)}; as such, the {nameof(WithGetKey)} must be specified to enable."); private Func? _onBeforeCreateOrUpdate; private Func? _updateModelMapper; + private bool _tenantFilterEnabled; + private bool _tenantFilterAllowBypass; /// /// Indicates whether and/or is supported for the . @@ -84,8 +86,11 @@ public EfDbModelOptions WithGetKey(Func getKey) /// tenant () checks automatically internally. /// The can be used to bypass these filters for queries as required. /// Each filter is applied individually in the order specified. + /// The is evaluated in two different contexts and must be expressible in both: against the real query (EF-translated to SQL) for , + /// and against an in-memory, single-item (LINQ-to-Objects) for the non-query pre-check performed by — this is intentional, avoiding a second database round-trip to + /// re-verify a model already in hand, but it means the predicate cannot use EF-only constructs (e.g. EF.Functions.* or provider-specific translations). /// - public EfDbModelOptions WithFilter(Func, IQueryable> filter, Func? nonQueryResult = null, bool allowFilterBypass = true) + public EfDbModelOptions WithFilter(Func, IQueryable> filter, Func? nonQueryResult = null, bool allowFilterBypass = false) { _filters.Add((filter.ThrowIfNull(), nonQueryResult, allowFilterBypass)); return this; @@ -111,19 +116,17 @@ public EfDbModelOptions WithLogicalDeleteFilter(bool allowFilterBypass = /// /// Indicates whether the filter can be bypassed via the ; defaults to . /// The to support fluent-style method-chaining. + /// Unlike , this is applied directly by using the resolved by the owning (see ) + /// rather than a stored predicate closure — this instance is commonly shared/cached across multiple instances (e.g. as a singleton service), so it cannot itself + /// hold a reference to any one caller's ; a stored closure would otherwise have no choice but to fall back to the ambient , which does not honour an + /// explicitly-injected, non-ambient passed to the constructor. public EfDbModelOptions WithTenantFilter(bool allowFilterBypass = false) { - if (TenantSupport.IsSupported) - { - WithFilter(q => - { - var tenantId = ExecutionContext.Current.TenantId; - return q.Where(m => ((IReadOnlyTenantId)m).TenantId == tenantId); - }, allowFilterBypass: allowFilterBypass); - } - else + if (!TenantSupport.IsSupported) throw new NotSupportedException($"{nameof(WithTenantFilter)} is not supported; model must implement {nameof(IReadOnlyTenantId)} to enable."); + _tenantFilterEnabled = true; + _tenantFilterAllowBypass = allowFilterBypass; return this; } @@ -139,23 +142,31 @@ public EfDbModelOptions WithTenantFilter(bool allowFilterBypass = false) /// /// The . /// The . + /// The resolved by the owning (see ); used only by the predicate, where configured. /// The filtered . /// This applies all specified filters to the excluding the non-query result handling; unless, is set to . /// See for more information. - public IQueryable ApplyFilters(EfDbArgs args, IQueryable query) + public IQueryable ApplyFilters(EfDbArgs args, IQueryable query, ExecutionContext executionContext) { query.ThrowIfNull(); - if (!HasFilters) - return query; - foreach (var (filter, _, allowFilterBypass) in _filters) + if (_tenantFilterEnabled && !(args.BypassFilters && _tenantFilterAllowBypass)) { - // Bypass filter where selected to do so and allowed. - if (args.BypassFilters && allowFilterBypass) - continue; + var tenantId = executionContext.ThrowIfNull().TenantId; + query = query.Where(m => ((IReadOnlyTenantId)m).TenantId == tenantId); + } + + if (HasFilters) + { + foreach (var (filter, _, allowFilterBypass) in _filters) + { + // Bypass filter where selected to do so and allowed. + if (args.BypassFilters && allowFilterBypass) + continue; - // Apply the filter. - query = filter(query); + // Apply the filter. + query = filter(query); + } } return query; diff --git a/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs b/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs index 9be21ba7..3340ead4 100644 --- a/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs +++ b/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs @@ -6,6 +6,7 @@ global using CoreEx.Entities; global using CoreEx.EntityFrameworkCore; global using CoreEx.Invokers; +global using CoreEx.Json; global using CoreEx.Mapping; global using CoreEx.Results; global using Microsoft.EntityFrameworkCore; diff --git a/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs b/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs index 11d704a6..9e38be70 100644 --- a/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs +++ b/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs @@ -8,5 +8,6 @@ public interface IEfDbContext /// /// Gets the base . /// + /// Must return the same instance on every access; subscribes to on this instance in its constructor and unsubscribes from it on using the same property, so a differing instance per call would leak the event handler. public IDatabase BaseDatabase { get; } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/README.md b/src/CoreEx.EntityFrameworkCore/README.md index a7de12a9..fe55ef80 100644 --- a/src/CoreEx.EntityFrameworkCore/README.md +++ b/src/CoreEx.EntityFrameworkCore/README.md @@ -1,6 +1,6 @@ # CoreEx.EntityFrameworkCore -> Provides the Entity Framework Core integration layer: `EfDb` as the CoreEx-EF bridge, `EfDbModel` and `EfDbMappedModel` for typed CRUD + query operations, `EfDbExtensions` for paged `IQueryable` mapping helpers, `EfDbInvoker` for OpenTelemetry tracing, and EF `ValueConverter` bridges for CoreEx converter types. +> Provides the Entity Framework Core integration layer: `EfDb` as the CoreEx-EF bridge, `EfDbModel` and `EfDbMappedModel` for typed CRUD + query operations, `EfDbExtensions` for paged `IQueryable` mapping helpers, `EfDbInvoker` for structured operation logging, and EF `ValueConverter` bridges for CoreEx converter types. ## Overview @@ -15,13 +15,13 @@ The central type is `EfDb`, which holds the `DbContext`, bridges its - 🔗 **EF + IDatabase bridge**: `EfDb` synchronizes EF Core's transaction with the underlying `IDatabase.CurrentTransaction`, so raw SQL commands and EF operations participate in the same ADO.NET transaction. - 📖 **Typed CRUD**: `EfDbModel` provides `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync` with automatic ETag/concurrency-token validation, tenant isolation, logical-delete filtering, and change-log stamping. - 🔁 **Mapped CRUD**: `EfDbMappedModel` layers a `IBiDirectionMapper` over `EfDbModel`, mapping between the domain entity type and the EF model type transparently for all CRUD operations. -- 🔍 **Query with dynamic filter/orderby**: `EfDbModel.Query(args?)` returns a fluent `EfDbQuery` that applies `QueryArgsConfig` filter and orderby, tenant and logical-delete predicates, and paging. -- 📄 **Paged IQueryable extensions**: `EfDbExtensions.ToMappedItemsResultAsync`, `ToItemsResultAsync`, `ToMappedCollectionAsync` convert an `IQueryable` to paged `ItemsResult` or collections with a mapper function or `IMapper`. -- 🏷️ **ETag / concurrency token**: `EfDbArgs.CheckETag` compares the incoming `ETag` against the current entity's concurrency token before update/delete, throwing `ConcurrencyException` on mismatch. -- 🔒 **Multi-tenancy filtering**: `EfDbModel` automatically adds `TenantId == executionContext.TenantId` predicates for entities implementing `IReadOnlyTenantId`. -- 🗑️ **Logical delete**: Entities implementing `IReadOnlyLogicallyDeleted` are soft-deleted (`IsDeleted = true`) on `DeleteAsync` rather than physically removed, and filtered out of `GetAsync` / `Query`. +- 🔍 **Query with configured filters**: `EfDbModel.Query(args?)` / `QueryTracked(args?)` return a plain `IQueryable` with any `WithFilter`/`WithTenantFilter`/`WithLogicalDeleteFilter` predicates from `EfDbModelOptions` applied; pair with `CoreEx.Data`'s `QueryArgsConfig` and the `EfDbExtensions` paging helpers to build dynamic filter/orderby/paged queries. +- 📄 **Paged `IQueryable` extensions**: `EfDbExtensions.ToItemsResultAsync`, `ToMappedItemsResultAsync`, `ToMappedItemsAsync` convert an `IQueryable` to paged `ItemsResult` or collections with a mapper function or `IMapper`. +- 🏷️ **ETag / concurrency token**: for a detached `UpdateAsync`, `EfDbModel` compares the incoming model's `ETag` against the freshly-fetched entity's concurrency token via `ETag.TryCompare`, returning a `ConcurrencyError` result on mismatch. +- 🔒 **Multi-tenancy**: non-query operations (`GetAsync`/`CreateAsync`/`UpdateAsync`/`DeleteAsync`) automatically reject a mismatched `IReadOnlyTenantId.TenantId` as not-found; `Query()` only applies the equivalent `TenantId == executionContext.TenantId` predicate when `EfDbModelOptions.WithTenantFilter()` has been configured. +- 🗑️ **Logical delete**: entities implementing `IReadOnlyLogicallyDeleted` are soft-deleted (`IsDeleted = true`) on `DeleteAsync` rather than physically removed; non-query operations automatically treat a logically-deleted row as not-found, while `Query()` only excludes them when `EfDbModelOptions.WithLogicalDeleteFilter()` has been configured. - 🔌 **ValueConverter bridge**: `ValueConverterBridge` and `JsonElementStringEfConverter` allow CoreEx `IConverter` implementations to be used directly as EF Core `ValueConverter` instances in `OnModelCreating`. -- 📡 **OpenTelemetry**: `EfDbInvoker` wraps every `EfDb` operation with an `Activity` span tagged with operation type, model type, and result. +- 📝 **Structured logging**: `EfDbInvoker` wraps every `EfDb` operation with structured log entries (tracing/`Activity` spans are intentionally disabled via `IsTracingDisabled`). ## Key types @@ -31,10 +31,10 @@ The central type is `EfDb`, which holds the `DbContext`, bridges its | **[`EfDbModel`](./EfDbModel.cs)** | Strongly-typed CRUD + query for a single EF model type: `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `Query(args?)`; applies full CoreEx cross-cutting pipeline. | | **[`EfDbMappedModel`](./EfDbMappedModel.cs)** | Adds a `IBiDirectionMapper` layer over `EfDbModel` for domain entity ↔ EF model type conversion; provides `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`. | | **[`EfDbExtensions`](./EfDbExtensions.cs)** | `IQueryable` extensions: `ToMappedItemsResultAsync`, `ToItemsResultAsync`, `ToMappedCollectionAsync`, `BuildQuery(QueryArgs, QueryArgsConfig)`. | -| **[`EfDbArgs`](./EfDbArgs.cs)** | Per-operation options: `OperationType`, `CheckETag`, `Paging`, `ExceptionHandler`; defaults sourced from `EfDbModelOptions` then `EfDbOptions`. | +| **[`EfDbArgs`](./EfDbArgs.cs)** | Per-operation options: `QueryTracking`, `ClearChangeTrackerAfterGet`, `SaveChanges`, `BypassFilters` (plus inherited `Refresh`/`TransformException`); defaults sourced from `EfDbModelOptions.Args` then `EfDb.Options.Args`. | | **[`EfDbOptions`](./EfDbOptions.cs)** | Instance-level options for `EfDb`: default `EfDbArgs`, per-model options registry via `GetOrAddModelOptions()`. | -| **[`EfDbModelOptions`](./EfDbModelOptions.cs)** | Per-model configuration: optional `EfDbArgs` override, `OnQuery` hook, tenant/logical-delete filtering enable/disable. | -| **[`EfDbInvoker`](./EfDbInvoker.cs)** | `InvokerBase` emitting OpenTelemetry spans and structured log entries for every EfDb operation; `Default` singleton used by `EfDb`. | +| **[`EfDbModelOptions`](./EfDbModelOptions.cs)** | Per-model configuration: `WithArgs`, `WithGetKey`, `WithFilter`/`WithTenantFilter`/`WithLogicalDeleteFilter`, `WithOnBeforeCreateOrUpdate`, `WithUpdateModelMapper`. | +| **[`EfDbInvoker`](./EfDbInvoker.cs)** | `InvokerBase` emitting structured log entries for every EfDb operation (tracing intentionally disabled); `Default` singleton used by `EfDb`. | | **[`ValueConverterBridge`](./Converters/ValueConverterBridgeT2.cs)** | EF Core `ValueConverter` that delegates to a CoreEx `IConverter`, bridging CoreEx converter types into EF model configuration. | | **[`JsonElementStringEfConverter`](./Converters/JsonElementStringEfConverter.cs)** | EF Core `ValueConverter` serializing `JsonElement` values to/from `string` for storing JSON fragments in a text column. | | [`IEfDb`](./IEfDb.cs) | Interface exposing `DbContext`, `IDatabase`, `EfDbOptions`, `ExecutionContext`, and `EfDbInvoker`; implemented by `EfDb`. | diff --git a/src/CoreEx.Template/content/CoreEx.Core/.template.config/template.json b/src/CoreEx.Template/content/CoreEx.Core/.template.config/template.json index e0df2614..09d02426 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/.template.config/template.json +++ b/src/CoreEx.Template/content/CoreEx.Core/.template.config/template.json @@ -43,10 +43,16 @@ "db-name": { "type": "derived", "valueSource": "name", - "valueTransform": "ValueAfterLastDotDelimitedLower", + "valueTransform": "ValueAfterLastDotDelimitedKebabCase", "fileRename": "db-name", "replaces": "db-name" }, + "pg-schema": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueAfterLastDotDelimitedSnakeCase", + "replaces": "pg-schema" + }, "solution-name": { "type": "derived", "valueSource": "name", @@ -319,10 +325,28 @@ "LowerCase": { "identifier": "lowerCase" }, + "PascalCaseToKebabCase": { + "identifier": "replace", + "pattern": "([a-z])([A-Z])", // insert hyphen at each lowercase→uppercase boundary (e.g. "ProductCatalog" → "Product-Catalog"). + "replacement": "$1-$2" + }, + "PascalCaseToSnakeCase": { + "identifier": "replace", + "pattern": "([a-z])([A-Z])", // insert underscore at each lowercase→uppercase boundary (e.g. "ProductCatalog" → "Product_Catalog"). + "replacement": "$1_$2" + }, "ValueAfterLastDotDelimitedLower": { "identifier": "chain", "steps": [ "ValueAfterLastDotDelimited", "LowerCase" ] }, + "ValueAfterLastDotDelimitedKebabCase": { + "identifier": "chain", + "steps": [ "ValueAfterLastDotDelimited", "PascalCaseToKebabCase", "LowerCase" ] + }, + "ValueAfterLastDotDelimitedSnakeCase": { + "identifier": "chain", + "steps": [ "ValueAfterLastDotDelimited", "PascalCaseToSnakeCase", "LowerCase" ] + }, "ValueSecondToLastDotDelimitedLower": { "identifier": "chain", "steps": [ "ValueSecondToLastDotDelimited", "LowerCase" ] diff --git a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Data/ref-data.seed.yaml b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Data/ref-data.seed.yaml index 5ead3f40..444baa81 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Data/ref-data.seed.yaml +++ b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Data/ref-data.seed.yaml @@ -1,5 +1,5 @@ # #if implement-sqlserver domain-name: [] # #elif implement-postgres -db-name: [] +pg-schema: [] # #endif \ No newline at end of file diff --git a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000001-create-db-name-schema.pgsql b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000001-create-db-name-schema.pgsql index 5c332891..cd1aeffe 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000001-create-db-name-schema.pgsql +++ b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000001-create-db-name-schema.pgsql @@ -1 +1 @@ -CREATE SCHEMA IF NOT EXISTS "db-name"; +CREATE SCHEMA IF NOT EXISTS "pg-schema"; diff --git a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000002-create-db-name-outbox-tables.pgsql b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000002-create-db-name-outbox-tables.pgsql index 5993a0c2..c3a82fbd 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000002-create-db-name-outbox-tables.pgsql +++ b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Migrations/date-now-000002-create-db-name-outbox-tables.pgsql @@ -1,8 +1,8 @@ --- Create table: "db-name"."outbox" and "db-name"."outbox_lease" +-- Create table: "pg-schema"."outbox" and "pg-schema"."outbox_lease" BEGIN; -CREATE TABLE "db-name"."outbox" ( +CREATE TABLE "pg-schema"."outbox" ( "outbox_id" BIGSERIAL NOT NULL PRIMARY KEY, "tenant_id" VARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. @@ -21,17 +21,17 @@ CREATE TABLE "db-name"."outbox" ( "lease_until_utc" TIMESTAMPTZ NULL -- Leased until UTC; after which assume released due to possible application crash. ); -CREATE INDEX "ix_db-name_outbox_partition_order" ON "db-name"."outbox" ("tenant_id", "partition_id", "outbox_id", "status", "available_utc", "lease_until_utc", "destination", "attempts"); -CREATE INDEX "ix_db-name_outbox_worker_pull" ON "db-name"."outbox" ("tenant_id", "partition_id", "status", "outbox_id", "available_utc"); -CREATE INDEX "ix_db-name_outbox_clean_up" ON "db-name"."outbox" ("outbox_id", "dequeued_utc") WHERE "status" = 2; +CREATE INDEX "ix_pg-schema_outbox_partition_order" ON "pg-schema"."outbox" ("tenant_id", "partition_id", "outbox_id", "status", "available_utc", "lease_until_utc", "destination", "attempts"); +CREATE INDEX "ix_pg-schema_outbox_worker_pull" ON "pg-schema"."outbox" ("tenant_id", "partition_id", "status", "outbox_id", "available_utc"); +CREATE INDEX "ix_pg-schema_outbox_clean_up" ON "pg-schema"."outbox" ("outbox_id", "dequeued_utc") WHERE "status" = 2; -CREATE TABLE "db-name"."outbox_lease" ( +CREATE TABLE "pg-schema"."outbox_lease" ( "tenant_id" VARCHAR(255) NOT NULL, -- '(none)' indicates no tenancy. "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. "lease_id" UUID NULL, -- Unique identifier of the lessee. "lease_until_utc" TIMESTAMPTZ NULL, -- Leased until UTC; after which assume released due to possible application crash. - CONSTRAINT "pk_db-name_outbox_lease" PRIMARY KEY ("tenant_id", "partition_id") + CONSTRAINT "pk_pg-schema_outbox_lease" PRIMARY KEY ("tenant_id", "partition_id") ); COMMIT; diff --git a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Program.cs b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Program.cs index be704f38..6b1f3473 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Program.cs +++ b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/Program.cs @@ -41,7 +41,7 @@ public static Task Main(string[] args) => PostgresMigrationConsole public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) { args.AddAssembly().AddAssembly(); // SqlStatement = CoreEx EF code-gen templates; Program = this project's embedded migrations/data. Both REQUIRED — the API tests call ConfigureMigrationArgs directly (not via Main), so the Database assembly must be added here. Do not remove. - args.DataResetFilterPredicate = ts => ts.Schema == "db-name"; // Only reset data for the specified schema. + args.DataResetFilterPredicate = ts => ts.Schema == "pg-schema"; // Only reset data for the specified schema. return args; } } diff --git a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/dbex.yaml b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/dbex.yaml index a1534c0b..73183649 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/dbex.yaml +++ b/src/CoreEx.Template/content/CoreEx.Core/tools/app-name.Database/dbex.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/DbEx/refs/heads/main/schema/dbex.json dotNetDataProjectPath: ../../src/app-name.Infrastructure # #if implement-postgres -schema: db-name +schema: pg-schema # #elseif (implement-sqlserver) schema: domain-name # #endif diff --git a/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs b/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs new file mode 100644 index 00000000..0f795740 --- /dev/null +++ b/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs @@ -0,0 +1,282 @@ +using CoreEx.Database.Postgres.Test.Unit.Contracts; +using CoreEx.Database.Postgres.Test.Unit.Models; +using CoreEx.Database.Postgres.Test.Unit.Repository; +using CoreEx.EntityFrameworkCore; +using CoreEx.EntityFrameworkCore.Converters; +using CoreEx.Mapping; +using CoreEx.Results; +using Microsoft.EntityFrameworkCore; + +namespace CoreEx.Database.Postgres.Test.Unit; + +public class EntityFrameworkBehaviorTests : DatabaseTestBase +{ + [Test] + public void CheckModel_UsesInjectedExecutionContext_NotAmbient() => Test.ScopedType(test => test.Run(async _ => + { + // Ambient ExecutionContext.TenantId is "A" (see EntryPoint.cs). A caller may instead construct EfDb with an explicit, + // non-ambient ExecutionContext (e.g. a background worker fanning out across tenants) - tenant checks must respect + // that injected instance rather than falling back to the ambient one. + var dc = ExecutionContext.GetRequiredService(); + var injectedContext = new ExecutionContext { TenantId = "Z" }; + var ef = new EfDb(dc, new EfDbOptions(), injectedContext); + + // Create stamps the model's TenantId from the injected context, not the ambient one. + var m = new TestTable { Id = Runtime.NewGuid(), Text = "InjectedCtx", Flag = true }; + var created = await ef.Model().CreateWithResultAsync(m).ConfigureAwait(false); + created.Value.Value.TenantId.Should().Be("Z"); + + // The same injected-context EfDb must be able to read back what it just wrote. + var got = await ef.Model().GetWithResultAsync(m.Id).ConfigureAwait(false); + got.IsSuccess.Should().BeTrue(); + got.Value.TenantId.Should().Be("Z"); + }).AssertSuccess()); + + [Test] + public void Query_TenantFilter_UsesInjectedExecutionContext_NotAmbient() => Test.ScopedType(test => test.Run(async _ => + { + // Ambient ExecutionContext.TenantId is "A" (see EntryPoint.cs). Construct a standalone EfDb with an explicit, different + // ExecutionContext and a tenant filter enabled - Query() must filter by the injected tenant, not the ambient one. + var dc = ExecutionContext.GetRequiredService(); + var injectedContext = new ExecutionContext { TenantId = "B" }; + var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter(allowFilterBypass: false)); + var ef = new EfDb(dc, options, injectedContext); + + // Seed data has two TenantId "B" rows (TableId 4 and 5); all others are "A" (see Data\data.yaml). + var count = await ef.Model().Query().CountAsync().ConfigureAwait(false); + count.Should().Be(2); + }).AssertSuccess()); + + [Test] + public void Get_NullTenantId_ThrowsInvalidOperationException() => Test.ScopedType(test => test.Run(async _ => + { + // TableId 1 was seeded with no TenantId (see Data\data.yaml) - simulates a legacy/pre-tenancy row. + var ef = ExecutionContext.GetRequiredService(); + var act = async () => await ef.Table.GetWithResultAsync(1.ToGuid()).ConfigureAwait(false); + await act.Should().ThrowAsync().WithMessage("*TenantId is null or empty*"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsAsync_Collection_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query(); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsAsync, TestTableDto>(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsAsync_List_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + // A null mapper must be rejected immediately, even when the query returns zero rows - otherwise the mapping delegate + // that would dereference it never executes, and the null mapper goes unnoticed. + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().Where(x => x.Text == "DefinitelyDoesNotExist12345"); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsAsync(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsResultAsync_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query(); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsResultAsync(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void WithFilter_DefaultDoesNotAllowBypass() => Test.ScopedType(test => test.Run(async _ => + { + var dc = ExecutionContext.GetRequiredService(); + var options = new EfDbOptions().WithModel(mo => mo.WithFilter(q => q.Where(x => x.Text == "Abc"))); + var ef = new EfDb(dc, options); + + var count = await ef.Model().Query().CountAsync().ConfigureAwait(false); + count.Should().Be(1); + + // Even with BypassFilters=true, a filter registered without an explicit allowFilterBypass must not be bypassable. + var countBypassed = await ef.Model().Query(new EfDbArgs { BypassFilters = true }).CountAsync().ConfigureAwait(false); + countBypassed.Should().Be(1); + }).AssertSuccess()); + + [Test] + public void Upsert_CreatesWhenNotFound() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var id = Runtime.NewGuid(); + var m = new TestTable { Id = id, Text = "Upserted", Flag = true }; + + var r = await ef.Table.UpsertAsync(m).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); + r.Value.Text.Should().Be("Upserted"); + + var got = await ef.Table.GetAsync(id).ConfigureAwait(false); + got.Should().NotBeNull(); + got.Text.Should().Be("Upserted"); + }).AssertSuccess()); + + [Test] + public void Upsert_UpdatesWhenFound() => Test.ScopedType(test => test.Run(async _ => + { + var id = 6.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + m.Text += "-Upsert"; + + var r = await ef.Table.UpsertAsync(m).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); + r.Value.Text.Should().Be(m.Text); + }).AssertSuccess()); + + [Test] + public void QueryTracked_EntitiesAreTracked() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + dc.ChangeTracker.Clear(); + + var tracked = await ef.Table.QueryTracked().FirstAsync(x => x.Id == 2.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().Contain(e => e.Entity == tracked); + + dc.ChangeTracker.Clear(); + var untracked = await ef.Table.Query().FirstAsync(x => x.Id == 2.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().NotContain(e => e.Entity == untracked); + }).AssertSuccess()); + + [Test] + public void ClearChangeTrackerAfterGet_DetachesUnrelatedTrackedEntities() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + dc.ChangeTracker.Clear(); + + // Simulate another repository call within the same scoped DbContext/unit of work having a tracked entity in flight. + var other = await ef.Table.QueryTracked().FirstAsync(x => x.Id == 3.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().Contain(e => e.Entity == other); + + var args = ef.Table.Args with { ClearChangeTrackerAfterGet = true }; + var m = await ef.Table.GetAsync(args, 2.ToGuid()).ConfigureAwait(false); + m.Should().NotBeNull(); + + // ChangeTracker.Clear() detaches the entire context, not just the row just fetched - the unrelated entity is gone too. + dc.ChangeTracker.Entries().Should().NotContain(e => e.Entity == other); + }).AssertSuccess()); + + [Test] + public void WithOnBeforeCreateOrUpdate_FailureShortCircuitsCreate() => Test.ScopedType(test => test.Run(async _ => + { + var dc = ExecutionContext.GetRequiredService(); + var options = new EfDbOptions().WithModel(mo => mo.WithOnBeforeCreateOrUpdate((m, _) => m.Text == "Blocked" ? Result.ValidationError("Text 'Blocked' is not allowed.") : Result.Success)); + var ef = new EfDb(dc, options); + + var r = await ef.Model().CreateWithResultAsync(new TestTable { Id = Runtime.NewGuid(), Text = "Blocked", Flag = true, TenantId = "A" }).ConfigureAwait(false); + r.IsValidationError.Should().BeTrue(); + + var allowed = await ef.Model().CreateWithResultAsync(new TestTable { Id = Runtime.NewGuid(), Text = "Allowed", Flag = true, TenantId = "A" }).ConfigureAwait(false); + allowed.IsSuccess.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void WithUpdateModelMapper_CustomMapperInvokedForDetachedUpdate() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + var id = 9.ToGuid(); + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + dc.ChangeTracker.Clear(); + + var originalNumber = m.Number; + m.Text += "-Custom"; + m.Number = (m.Number ?? 0) + 1000; + + // A custom options instance sharing the same underlying DbContext, but with an updateModelMapper that only copies Text (ignores Number). + var options = new EfDbOptions().WithModel(mo => mo.WithUpdateModelMapper((update, existing) => + { + existing.Text = update.Text; + return true; + })); + var customEf = new EfDb(dc, options); + + var u = await customEf.Model().UpdateAsync(m).ConfigureAwait(false); + u.Value.Text.Should().Be(m.Text); + u.Value.Number.Should().Be(originalNumber); // Number change was ignored by the custom mapper. + }).AssertSuccess()); + + [Test] + public void Update_Attached_StaleETag_WithoutEfConcurrencyToken_ReturnsConcurrencyError() => Test.ScopedType(test => test.Run(async _ => + { + // Unlike TestDbContext (which maps ETag with .IsRowVersion(), giving EF's own SaveChanges a native concurrency check), + // this context maps the same table/type without a concurrency token - the scenario where an attached update previously + // had no protection at all against a row changed by someone else between the read and the write. + var database = ExecutionContext.GetRequiredService(); + var dc = new NoConcurrencyTokenDbContext(new DbContextOptionsBuilder().Options, database); + var ef = new EfDb(dc, new EfDbOptions()); + + var id = Runtime.NewGuid(); + var created = await ef.Model().CreateWithResultAsync(new TestTable { Id = id, Text = "Original", Flag = true }).ConfigureAwait(false); + created.IsSuccess.Should().BeTrue(); + + // Fetch and track it (attached, not detached) via this context. + var tracked = await ef.Model().GetAsync(id).ConfigureAwait(false); + tracked.Should().NotBeNull(); + + // Simulate another process changing the row directly; this bumps the database-generated xmin underneath the tracked copy. + await database.Statement("UPDATE \"test\".\"table\" SET \"text\" = @Text WHERE \"table_id\" = @Id").Param("Text", "ChangedElsewhere").Param("Id", id).NonQueryAsync().ConfigureAwait(false); + + // Mutate the now-stale tracked entity and attempt to save it. + tracked.Text = "MyChange"; + var r = await ef.Model().UpdateWithResultAsync(tracked).ConfigureAwait(false); + r.IsConcurrencyError.Should().BeTrue(); + }).AssertSuccess()); + + private sealed class NoConcurrencyTokenDbContext(DbContextOptions options, PostgresDatabase database) : DbContext(options), IEfDbContext + { + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseNpgsql(BaseDatabase.Connection, contextOwnsConnection: false); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(e => + { + e.ToTable("table", "test"); + e.HasKey(nameof(TestTable.Id)); + e.Property(p => p.Id).HasColumnName("table_id").HasColumnType("uuid"); + e.Property(p => p.Text).HasColumnName("text").HasColumnType("varchar(200)"); + e.Property(p => p.TenantId).HasColumnName("tenant_id").HasColumnType("varchar(20)"); + // Deliberately no .IsConcurrencyToken()/.IsRowVersion() - only value-generation, so EF's SaveChanges performs no concurrency check of its own. + e.Property(p => p.ETag).HasColumnName("xmin").HasColumnType("xid").ValueGeneratedOnAddOrUpdate().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Property(p => p.CreatedBy).HasColumnName("created_by").HasColumnType("varchar(250)").ValueGeneratedOnUpdate(); + e.Property(p => p.CreatedOn).HasColumnName("created_on").HasColumnType("timestamptz").ValueGeneratedOnUpdate(); + e.Property(p => p.UpdatedBy).HasColumnName("updated_by").HasColumnType("varchar(250)").ValueGeneratedOnAdd(); + e.Property(p => p.UpdatedOn).HasColumnName("updated_on").HasColumnType("timestamptz").ValueGeneratedOnAdd(); + e.Property(p => p.IsDeleted).HasColumnName("is_deleted").HasColumnType("boolean").HasDefaultValue(false); + e.Ignore(p => p.Number); + e.Ignore(p => p.Amount); + e.Ignore(p => p.Flag); + e.Ignore(p => p.Date); + e.Ignore(p => p.Time); + e.Ignore(p => p.Json); + }); + } + } +} diff --git a/tests/CoreEx.Database.Postgres.Test.Unit/PostgresDatabaseTests.cs b/tests/CoreEx.Database.Postgres.Test.Unit/PostgresDatabaseTests.cs new file mode 100644 index 00000000..a4c7f54b --- /dev/null +++ b/tests/CoreEx.Database.Postgres.Test.Unit/PostgresDatabaseTests.cs @@ -0,0 +1,69 @@ +using Npgsql; + +namespace CoreEx.Database.Postgres.Test.Unit; + +[TestFixture] +public class PostgresDatabaseTests +{ + // Regression test: the constructor used to call dataSource.CreateConnection() directly in the base-constructor + // argument list, so a null dataSource surfaced as a raw NullReferenceException instead of ArgumentNullException. + [Test] + public void Constructor_NullDataSource_ThrowsArgumentNullException() + { + NpgsqlDataSource dataSource = null!; + Action act = () => new PostgresDatabase(dataSource); + act.Should().Throw().WithParameterName("dataSource"); + } + + [TestCase("56001", typeof(ValidationException))] + [TestCase("56002", typeof(BusinessException))] + [TestCase("56003", typeof(AuthorizationException))] + [TestCase("56004", typeof(ConcurrencyException))] + [TestCase("56005", typeof(NotFoundException))] + [TestCase("56006", typeof(ConflictException))] + [TestCase("56007", typeof(DuplicateException))] + [TestCase("56010", typeof(DataConsistencyException))] + public void OnDbException_MapsKnownSqlStateToSemanticException(string sqlState, Type expectedType) + { + var pex = new PostgresException("Test message.", "ERROR", "ERROR", sqlState); + var hex = ((IDatabase)CreateDatabase()).HandleDbException(pex); + + hex.Should().NotBeNull(); + hex.Should().BeOfType(expectedType); + hex!.InnerException.Should().BeSameAs(pex); + hex.Message.Should().Be(pex.Message.TrimEnd()); + } + + [Test] + public void OnDbException_DefaultDuplicateSqlState_MapsToDuplicateException() + { + var pex = new PostgresException("Unique violation.", "ERROR", "ERROR", "23505"); + var hex = ((IDatabase)CreateDatabase()).HandleDbException(pex); + + hex.Should().BeOfType(); + hex!.InnerException.Should().BeSameAs(pex); + } + + [Test] + public void OnDbException_CheckDuplicateErrorNumbersDisabled_DoesNotMapDuplicateSqlState() + { + var db = CreateDatabase(); + db.CheckDuplicateErrorNumbers = false; + + var pex = new PostgresException("Unique violation.", "ERROR", "ERROR", "23505"); + var hex = ((IDatabase)db).HandleDbException(pex); + + hex.Should().BeNull(); + } + + [Test] + public void OnDbException_UnmappedSqlState_ReturnsNull() + { + var pex = new PostgresException("Some other error.", "ERROR", "ERROR", "42601"); + var hex = ((IDatabase)CreateDatabase()).HandleDbException(pex); + + hex.Should().BeNull(); + } + + private static PostgresDatabase CreateDatabase() => new(NpgsqlDataSource.Create("Host=localhost;Database=dummy;Username=dummy;Password=dummy")); +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/004-create-sp-set-session-context.sql b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/004-create-sp-set-session-context.sql new file mode 100644 index 00000000..888b541f --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/004-create-sp-set-session-context.sql @@ -0,0 +1,14 @@ +CREATE OR ALTER PROCEDURE [dbo].[spSetSessionContext] + @Username NVARCHAR(250) = NULL, + @Timestamp DATETIMEOFFSET = NULL, + @TenantId NVARCHAR(50) = NULL, + @UserId NVARCHAR(50) = NULL +AS +BEGIN + SET NOCOUNT ON; + + EXEC sys.sp_set_session_context @key = N'Username', @value = @Username; + EXEC sys.sp_set_session_context @key = N'Timestamp', @value = @Timestamp; + EXEC sys.sp_set_session_context @key = N'TenantId', @value = @TenantId; + EXEC sys.sp_set_session_context @key = N'UserId', @value = @UserId; +END diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs b/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs index fd710623..83d3f965 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs @@ -24,4 +24,4 @@ public static Task Main(string[] args) => SqlServerMigrationConsole /// The . /// The . public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) => args.AddAssembly().IncludeExtendedSchemaScripts(); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseErrorMappingTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseErrorMappingTests.cs new file mode 100644 index 00000000..8e60d142 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseErrorMappingTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Data.SqlClient; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class DatabaseErrorMappingTests : DatabaseTestBase +{ + [TestCase(56001, typeof(ValidationException))] + [TestCase(56002, typeof(BusinessException))] + [TestCase(56003, typeof(AuthorizationException))] + [TestCase(56004, typeof(ConcurrencyException))] + [TestCase(56005, typeof(NotFoundException))] + [TestCase(56006, typeof(ConflictException))] + [TestCase(56007, typeof(DuplicateException))] + [TestCase(56010, typeof(DataConsistencyException))] + public void OnDbException_MapsKnownErrorNumberToSemanticException(int errorNumber, Type expectedType) => Test.ScopedType(test => + { + test.Run(async db => + { + var act = () => db.Statement($"THROW {errorNumber}, N'Test message.', 1;").NonQueryAsync(); + var ex = await act.Should().ThrowAsync().ConfigureAwait(false); + + ex.Which.Should().BeOfType(expectedType); + ex.Which.Message.Should().Be("Test message."); + ex.Which.InnerException.Should().BeOfType(); + }).AssertSuccess(); + }); + + [Test] + public void OnDbException_UniqueIndexViolation_MapsToDuplicateException() => Test.ScopedType(test => + { + test.Run(async db => + { + // TableId 2 already has Text='Abc', TenantId='A' (see Data\data.yaml); the unique index on (TenantId, Text) rejects a second row with the same combination. + var act = () => db.Statement("INSERT INTO [Test].[Table] (Text, TenantId) VALUES ('Abc', 'A');").NonQueryAsync(); + var ex = await act.Should().ThrowAsync().ConfigureAwait(false); + + ex.Which.InnerException.Should().BeOfType().Which.Number.Should().Be(2601); + }).AssertSuccess(); + }); + + [Test] + public void OnDbException_UnmappedErrorNumber_PropagatesOriginalSqlException() => Test.ScopedType(test => + { + test.Run(async db => + { + var act = () => db.Statement("THROW 56099, N'Unmapped error.', 1;").NonQueryAsync(); + var ex = await act.Should().ThrowAsync().ConfigureAwait(false); + + ex.Which.Number.Should().Be(56099); + }).AssertSuccess(); + }); +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseRecordTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseRecordTests.cs new file mode 100644 index 00000000..22b5347e --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseRecordTests.cs @@ -0,0 +1,89 @@ +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class DatabaseRecordTests : DatabaseTestBase +{ + [Test] + public void TryGetValue_And_GetValueOrDefault() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectAsync(r => + { + r.TryGetValue("Text", out var text).Should().BeTrue(); + text.Should().Be("Abc"); + + r.TryGetValue("DoesNotExist", out var missing).Should().BeFalse(); + missing.Should().BeNull(); + + r.GetValueOrDefault("Number").Should().Be(123); + r.GetValueOrDefault("DoesNotExist", 999).Should().Be(999); + + return false; + }).ConfigureAwait(false); + }).AssertSuccess(); + }); + + [Test] + public void TryGetOrdinal_FoundAndNotFound() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectAsync(r => + { + r.TryGetOrdinal("Text", out var ordinal).Should().BeTrue(); + ordinal.Should().BeGreaterThanOrEqualTo(0); + + r.TryGetOrdinal("DoesNotExist", out _).Should().BeFalse(); + + return false; + }).ConfigureAwait(false); + }).AssertSuccess(); + }); + + [Test] + public void IsDBNull_ForNullAndNonNullColumns() => Test.ScopedType(test => + { + test.Run(async db => + { + // TableId 1 has no Text/Number/etc. set (see Data\data.yaml). + await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 1.ToGuid()).SelectAsync(r => + { + r.IsDBNull("Text", out var textOrdinal).Should().BeTrue(); + textOrdinal.Should().BeGreaterThanOrEqualTo(0); + + r.IsDBNull("CreatedBy", out _).Should().BeFalse(); + + return false; + }).ConfigureAwait(false); + }).AssertSuccess(); + }); + + [Test] + public void GetRowVersion_ReturnsNonEmptyEncodedValue() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectAsync(r => + { + r.GetRowVersion().Should().NotBeNullOrEmpty(); + return false; + }).ConfigureAwait(false); + }).AssertSuccess(); + }); + + [Test] + public void GetValueFromJson_DeserializesJsonColumn() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectAsync(r => + { + var kvp = r.GetValueFromJson>("KvpJson"); + kvp.Should().NotBeNull(); + kvp!["Key"].Should().Be("Value"); + + return false; + }).ConfigureAwait(false); + }).AssertSuccess(); + }); +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs new file mode 100644 index 00000000..658f8c53 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs @@ -0,0 +1,282 @@ +using CoreEx.Database.SqlServer.Test.Unit.Contracts; +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.EntityFrameworkCore; +using CoreEx.EntityFrameworkCore.Converters; +using CoreEx.Mapping; +using CoreEx.Results; +using Microsoft.EntityFrameworkCore; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class EntityFrameworkBehaviorTests : DatabaseTestBase +{ + [Test] + public void CheckModel_UsesInjectedExecutionContext_NotAmbient() => Test.ScopedType(test => test.Run(async _ => + { + // Ambient ExecutionContext.TenantId is "A" (see EntryPoint.cs). A caller may instead construct EfDb with an explicit, + // non-ambient ExecutionContext (e.g. a background worker fanning out across tenants) - tenant checks must respect + // that injected instance rather than falling back to the ambient one. + var dc = ExecutionContext.GetRequiredService(); + var injectedContext = new ExecutionContext { TenantId = "Z" }; + var ef = new EfDb(dc, new EfDbOptions(), injectedContext); + + // Create stamps the model's TenantId from the injected context, not the ambient one. + var m = new TestTable { Id = Runtime.NewGuid(), Text = "InjectedCtx", Flag = true }; + var created = await ef.Model().CreateWithResultAsync(m).ConfigureAwait(false); + created.Value.Value.TenantId.Should().Be("Z"); + + // The same injected-context EfDb must be able to read back what it just wrote. + var got = await ef.Model().GetWithResultAsync(m.Id).ConfigureAwait(false); + got.IsSuccess.Should().BeTrue(); + got.Value.TenantId.Should().Be("Z"); + }).AssertSuccess()); + + [Test] + public void Query_TenantFilter_UsesInjectedExecutionContext_NotAmbient() => Test.ScopedType(test => test.Run(async _ => + { + // Ambient ExecutionContext.TenantId is "A" (see EntryPoint.cs). Construct a standalone EfDb with an explicit, different + // ExecutionContext and a tenant filter enabled - Query() must filter by the injected tenant, not the ambient one. + var dc = ExecutionContext.GetRequiredService(); + var injectedContext = new ExecutionContext { TenantId = "B" }; + var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter(allowFilterBypass: false)); + var ef = new EfDb(dc, options, injectedContext); + + // Seed data has two TenantId "B" rows (TableId 4 and 5); all others are "A" (see Data\data.yaml). + var count = await ef.Model().Query().CountAsync().ConfigureAwait(false); + count.Should().Be(2); + }).AssertSuccess()); + + [Test] + public void Get_NullTenantId_ThrowsInvalidOperationException() => Test.ScopedType(test => test.Run(async _ => + { + // TableId 1 was seeded with no TenantId (see Data\data.yaml) - simulates a legacy/pre-tenancy row. + var ef = ExecutionContext.GetRequiredService(); + var act = async () => await ef.Table.GetWithResultAsync(1.ToGuid()).ConfigureAwait(false); + await act.Should().ThrowAsync().WithMessage("*TenantId is null or empty*"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsAsync_Collection_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query(); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsAsync, TestTableDto>(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsAsync_List_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + // A null mapper must be rejected immediately, even when the query returns zero rows - otherwise the mapping delegate + // that would dereference it never executes, and the null mapper goes unnoticed. + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().Where(x => x.Text == "DefinitelyDoesNotExist12345"); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsAsync(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void ToMappedItemsResultAsync_NullMapper_ThrowsArgumentNullException() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query(); + + IMapper mapper = null!; + var act = async () => await q.ToMappedItemsResultAsync(mapper).ConfigureAwait(false); + await act.Should().ThrowAsync().WithParameterName("mapper"); + }).AssertSuccess()); + + [Test] + public void WithFilter_DefaultDoesNotAllowBypass() => Test.ScopedType(test => test.Run(async _ => + { + var dc = ExecutionContext.GetRequiredService(); + var options = new EfDbOptions().WithModel(mo => mo.WithFilter(q => q.Where(x => x.Text == "Abc"))); + var ef = new EfDb(dc, options); + + var count = await ef.Model().Query().CountAsync().ConfigureAwait(false); + count.Should().Be(1); + + // Even with BypassFilters=true, a filter registered without an explicit allowFilterBypass must not be bypassable. + var countBypassed = await ef.Model().Query(new EfDbArgs { BypassFilters = true }).CountAsync().ConfigureAwait(false); + countBypassed.Should().Be(1); + }).AssertSuccess()); + + [Test] + public void Upsert_CreatesWhenNotFound() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var id = Runtime.NewGuid(); + var m = new TestTable { Id = id, Text = "Upserted", Flag = true }; + + var r = await ef.Table.UpsertAsync(m).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); + r.Value.Text.Should().Be("Upserted"); + + var got = await ef.Table.GetAsync(id).ConfigureAwait(false); + got.Should().NotBeNull(); + got.Text.Should().Be("Upserted"); + }).AssertSuccess()); + + [Test] + public void Upsert_UpdatesWhenFound() => Test.ScopedType(test => test.Run(async _ => + { + var id = 6.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + m.Text += "-Upsert"; + + var r = await ef.Table.UpsertAsync(m).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); + r.Value.Text.Should().Be(m.Text); + }).AssertSuccess()); + + [Test] + public void QueryTracked_EntitiesAreTracked() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + dc.ChangeTracker.Clear(); + + var tracked = await ef.Table.QueryTracked().FirstAsync(x => x.Id == 2.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().Contain(e => e.Entity == tracked); + + dc.ChangeTracker.Clear(); + var untracked = await ef.Table.Query().FirstAsync(x => x.Id == 2.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().NotContain(e => e.Entity == untracked); + }).AssertSuccess()); + + [Test] + public void ClearChangeTrackerAfterGet_DetachesUnrelatedTrackedEntities() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + dc.ChangeTracker.Clear(); + + // Simulate another repository call within the same scoped DbContext/unit of work having a tracked entity in flight. + var other = await ef.Table.QueryTracked().FirstAsync(x => x.Id == 3.ToGuid()).ConfigureAwait(false); + dc.ChangeTracker.Entries().Should().Contain(e => e.Entity == other); + + var args = ef.Table.Args with { ClearChangeTrackerAfterGet = true }; + var m = await ef.Table.GetAsync(args, 2.ToGuid()).ConfigureAwait(false); + m.Should().NotBeNull(); + + // ChangeTracker.Clear() detaches the entire context, not just the row just fetched - the unrelated entity is gone too. + dc.ChangeTracker.Entries().Should().NotContain(e => e.Entity == other); + }).AssertSuccess()); + + [Test] + public void WithOnBeforeCreateOrUpdate_FailureShortCircuitsCreate() => Test.ScopedType(test => test.Run(async _ => + { + var dc = ExecutionContext.GetRequiredService(); + var options = new EfDbOptions().WithModel(mo => mo.WithOnBeforeCreateOrUpdate((m, _) => m.Text == "Blocked" ? Result.ValidationError("Text 'Blocked' is not allowed.") : Result.Success)); + var ef = new EfDb(dc, options); + + var r = await ef.Model().CreateWithResultAsync(new TestTable { Id = Runtime.NewGuid(), Text = "Blocked", Flag = true, TenantId = "A" }).ConfigureAwait(false); + r.IsValidationError.Should().BeTrue(); + + var allowed = await ef.Model().CreateWithResultAsync(new TestTable { Id = Runtime.NewGuid(), Text = "Allowed", Flag = true, TenantId = "A" }).ConfigureAwait(false); + allowed.IsSuccess.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void WithUpdateModelMapper_CustomMapperInvokedForDetachedUpdate() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + var id = 9.ToGuid(); + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + dc.ChangeTracker.Clear(); + + var originalNumber = m.Number; + m.Text += "-Custom"; + m.Number = (m.Number ?? 0) + 1000; + + // A custom options instance sharing the same underlying DbContext, but with an updateModelMapper that only copies Text (ignores Number). + var options = new EfDbOptions().WithModel(mo => mo.WithUpdateModelMapper((update, existing) => + { + existing.Text = update.Text; + return true; + })); + var customEf = new EfDb(dc, options); + + var u = await customEf.Model().UpdateAsync(m).ConfigureAwait(false); + u.Value.Text.Should().Be(m.Text); + u.Value.Number.Should().Be(originalNumber); // Number change was ignored by the custom mapper. + }).AssertSuccess()); + + [Test] + public void Update_Attached_StaleETag_WithoutEfConcurrencyToken_ReturnsConcurrencyError() => Test.ScopedType(test => test.Run(async _ => + { + // Unlike TestDbContext (which maps ETag with .IsRowVersion(), giving EF's own SaveChanges a native concurrency check), + // this context maps the same table/type without a concurrency token - the scenario where an attached update previously + // had no protection at all against a row changed by someone else between the read and the write. + var database = ExecutionContext.GetRequiredService(); + var dc = new NoConcurrencyTokenDbContext(new DbContextOptionsBuilder().Options, database); + var ef = new EfDb(dc, new EfDbOptions()); + + var id = Runtime.NewGuid(); + var created = await ef.Model().CreateWithResultAsync(new TestTable { Id = id, Text = "Original", Flag = true }).ConfigureAwait(false); + created.IsSuccess.Should().BeTrue(); + + // Fetch and track it (attached, not detached) via this context. + var tracked = await ef.Model().GetAsync(id).ConfigureAwait(false); + tracked.Should().NotBeNull(); + + // Simulate another process changing the row directly; this bumps the database-generated RowVersion underneath the tracked copy. + await database.Statement("UPDATE [Test].[Table] SET [Text] = @Text WHERE [TableId] = @Id").Param("Text", "ChangedElsewhere").Param("Id", id).NonQueryAsync().ConfigureAwait(false); + + // Mutate the now-stale tracked entity and attempt to save it. + tracked.Text = "MyChange"; + var r = await ef.Model().UpdateWithResultAsync(tracked).ConfigureAwait(false); + r.IsConcurrencyError.Should().BeTrue(); + }).AssertSuccess()); + + private sealed class NoConcurrencyTokenDbContext(DbContextOptions options, SqlServerDatabase database) : DbContext(options), IEfDbContext + { + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlServer(BaseDatabase.Connection, contextOwnsConnection: false); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(e => + { + e.ToTable("Table", "Test"); + e.HasKey(nameof(TestTable.Id)); + e.Property(p => p.Id).HasColumnName("TableId").HasColumnType("UNIQUEIDENTIFIER"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(200)"); + e.Property(p => p.TenantId).HasColumnName("TenantId").HasColumnType("NVARCHAR(20)"); + // Deliberately no .IsConcurrencyToken()/.IsRowVersion() - only value-generation, so EF's SaveChanges performs no concurrency check of its own. + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").ValueGeneratedOnAddOrUpdate().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)").ValueGeneratedOnUpdate(); + e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET").ValueGeneratedOnUpdate(); + e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)").ValueGeneratedOnAdd(); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET").ValueGeneratedOnAdd(); + e.Property(p => p.IsDeleted).HasColumnName("IsDeleted").HasColumnType("BIT").HasDefaultValue(false); + e.Ignore(p => p.Number); + e.Ignore(p => p.Amount); + e.Ignore(p => p.Flag); + e.Ignore(p => p.Date); + e.Ignore(p => p.Time); + e.Ignore(p => p.KvpJson); + }); + } + } +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerExtensionsParametersTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerExtensionsParametersTests.cs new file mode 100644 index 00000000..dfbe6d02 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerExtensionsParametersTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Data.SqlClient; +using System.Data; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +[TestFixture] +public class SqlServerExtensionsParametersTests +{ + [Test] + public void AddParameter_WithSqlDbType() + { + var dp = CreateCollection().AddParameter("foo", 123, SqlDbType.Int); + dp.ParameterName.Should().Be("@foo"); + dp.Value.Should().Be(123); + dp.SqlDbType.Should().Be(SqlDbType.Int); + dp.Direction.Should().Be(ParameterDirection.Input); + } + + [Test] + public void Param_AddsParameter() + { + var collection = CreateCollection().Param("foo", 123, SqlDbType.Int); + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@foo"); + } + + [Test] + public void ParamWhen_OnlyAddsWhenTrue() + { + var collection = CreateCollection() + .ParamWhen(false, "foo", () => 1, SqlDbType.Int) + .ParamWhen(true, "bar", () => 2, SqlDbType.Int); + + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@bar"); + } + + // Regression test: ParamWith(object? with, ...) used to force-cast the boxed `with` value to the + // value's own type T via `(T)with`, throwing InvalidCastException whenever the "check" type (here, Guid) + // differed from the parameter's value type (here, string). Fixed by using independent TWith/TValue generics, + // mirroring the base CoreEx.Database.ParamWith convention. + [Test] + public void ParamWith_MismatchedCheckAndValueTypes_DoesNotThrow() + { + var tenantId = Guid.NewGuid(); + Action act = () => CreateCollection().ParamWith(tenantId, "tenant", () => tenantId.ToString(), SqlDbType.NVarChar); + act.Should().NotThrow(); + + var collection = CreateCollection().ParamWith(tenantId, "tenant", () => tenantId.ToString(), SqlDbType.NVarChar); + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@tenant"); + collection[0].Value.Should().Be(tenantId.ToString()); + } + + [Test] + public void ParamWith_DefaultCheckValue_DoesNotAddParameter() + { + var collection = CreateCollection().ParamWith(Guid.Empty, "tenant", () => "abc", SqlDbType.NVarChar); + collection.Count.Should().Be(0); + } + + [Test] + public void ParamWith_SameCheckAndValueType_StillWorks() + { + var collection = CreateCollection().ParamWith("abc", "foo", null, SqlDbType.NVarChar); + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@foo"); + collection[0].Value.Should().Be("abc"); + } + + private static SqlServerDatabase CreateDatabase() => new((SqlConnection)SqlClientFactory.Instance.CreateConnection()); + + private static DatabaseParameterCollection CreateCollection() => CreateDatabase().Statement(SqlStatement.FromText("SELECT 1")).Parameters; +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerSessionContextTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerSessionContextTests.cs new file mode 100644 index 00000000..fbd4ca57 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/SqlServerSessionContextTests.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class SqlServerSessionContextTests : DatabaseTestBase +{ + [Test] + public void SetSqlSessionContextAsync_SetsAllValues() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.SetSqlSessionContextAsync("test-user", DateTimeOffset.UtcNow, "tenant-x", "user-123").ConfigureAwait(false); + + (await GetSessionContextAsync(db, "Username").ConfigureAwait(false)).Should().Be("test-user"); + (await GetSessionContextAsync(db, "TenantId").ConfigureAwait(false)).Should().Be("tenant-x"); + (await GetSessionContextAsync(db, "UserId").ConfigureAwait(false)).Should().Be("user-123"); + }).AssertSuccess(); + }); + + // Regression coverage for the ParamWith cast fix: tenantId/userId are added only "with" a non-default value; when omitted they must not be passed at all (remaining unset in SESSION_CONTEXT), not fail with a cast exception. + [Test] + public void SetSqlSessionContextAsync_OmittedTenantAndUser_LeavesSessionContextUnset() => Test.ScopedType(test => + { + test.Run(async db => + { + await db.SetSqlSessionContextAsync("only-user", DateTimeOffset.UtcNow).ConfigureAwait(false); + + (await GetSessionContextAsync(db, "Username").ConfigureAwait(false)).Should().Be("only-user"); + (await GetSessionContextAsync(db, "TenantId").ConfigureAwait(false)).Should().BeNull(); + (await GetSessionContextAsync(db, "UserId").ConfigureAwait(false)).Should().BeNull(); + }).AssertSuccess(); + }); + + private static Task GetSessionContextAsync(SqlServerDatabase db, string key) + => db.Statement($"SELECT CAST(SESSION_CONTEXT(N'{key}') AS NVARCHAR(250))").ScalarAsync(); +} diff --git a/tests/CoreEx.Database.Test.Unit/DatabaseCommandTests.cs b/tests/CoreEx.Database.Test.Unit/DatabaseCommandTests.cs new file mode 100644 index 00000000..8bef1d5c --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/DatabaseCommandTests.cs @@ -0,0 +1,20 @@ +using CoreEx.Database.SqlServer; +using Microsoft.Data.SqlClient; + +namespace CoreEx.Database.Test.Unit; + +[TestFixture] +public class DatabaseCommandTests +{ + [Test] + public async Task NonQueryAsync_IndeterminateStatement_ThrowsBeforeOpeningConnection() + { + var database = new SqlServerDatabase((SqlConnection)SqlClientFactory.Instance.CreateConnection()); + Func act = () => database.Statement(SqlStatement.Indeterminate).NonQueryAsync(); + + await act.Should().ThrowAsync().ConfigureAwait(false); + + // The connection must never have been opened; the guard fires before any database I/O is attempted. + ((IDatabase)database).Connection.State.Should().Be(System.Data.ConnectionState.Closed); + } +} diff --git a/tests/CoreEx.Database.Test.Unit/DatabaseWildcardTests.cs b/tests/CoreEx.Database.Test.Unit/DatabaseWildcardTests.cs new file mode 100644 index 00000000..271bfb64 --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/DatabaseWildcardTests.cs @@ -0,0 +1,38 @@ +using CoreEx.Database.Extended; +using CoreEx.Wildcards; + +namespace CoreEx.Database.Test.Unit; + +[TestFixture] +public class DatabaseWildcardTests +{ + [Test] + public void Replace_DefaultWildcard_ConvertsMultiWildcardOnly() => new DatabaseWildcard().Replace("abc*").Should().Be("abc%"); + + [Test] + public void Replace_BothAll_ConvertsMultiAndSingleWildcards() => new DatabaseWildcard(Wildcard.BothAll).Replace("a*b?c").Should().Be("a%b_c"); + + [Test] + public void Replace_BothAll_EscapesLiteralWildcardCharacters() => new DatabaseWildcard(Wildcard.BothAll).Replace("a%b_c").Should().Be("a[%]b[_]c"); + + [Test] + public void Replace_CustomDatabaseWildcardCharacters_AreUsedInsteadOfDefaults() + => new DatabaseWildcard(Wildcard.BothAll, multiWildcard: '#', singleWildcard: '!').Replace("a*b?c").Should().Be("a#b!c"); + + [Test] + public void Replace_MatchAllSelection_ReturnsSingleMultiWildcardCharacter() => new DatabaseWildcard(Wildcard.MultiAll).Replace("*").Should().Be("%"); + + [Test] + public void Constructor_SameMultiAndSingleWildcardCharacter_Throws() + { + Action act = () => new DatabaseWildcard(Wildcard.BothAll, multiWildcard: '%', singleWildcard: '%'); + act.Should().Throw().WithParameterName("multiWildcard"); + } + + [Test] + public void Constructor_UnsupportedSingleWildcard_WithoutCharacter_Throws() + { + Action act = () => new DatabaseWildcard(Wildcard.BothAll, singleWildcard: char.MinValue); + act.Should().Throw(); + } +} diff --git a/tests/CoreEx.Database.Test.Unit/MultiSetCollArgsTests.cs b/tests/CoreEx.Database.Test.Unit/MultiSetCollArgsTests.cs new file mode 100644 index 00000000..bec0350a --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/MultiSetCollArgsTests.cs @@ -0,0 +1,29 @@ +using CoreEx.Database.Extended; + +namespace CoreEx.Database.Test.Unit; + +[TestFixture] +public class MultiSetCollArgsTests +{ + [TestCase(0, 10)] // Regression: previously threw for any valid (minimumRows <= maximumRows) combination. + [TestCase(5, 5)] // Equal bounds are valid. + [TestCase(0, null)] + public void Constructor_ValidBounds_DoesNotThrow(int minimumRows, int? maximumRows) + { + Action act = () => new TestMultiSetCollArgs(minimumRows, maximumRows); + act.Should().NotThrow(); + } + + [TestCase(10, 5)] // Regression: previously did not throw despite minimumRows > maximumRows. + [TestCase(1, 0)] + public void Constructor_InvalidBounds_Throws(int minimumRows, int? maximumRows) + { + Action act = () => new TestMultiSetCollArgs(minimumRows, maximumRows); + act.Should().Throw().WithParameterName(nameof(maximumRows)); + } + + private sealed class TestMultiSetCollArgs(int minimumRows = 0, int? maximumRows = null, bool stopOnNull = false) : MultiSetCollArgs(minimumRows, maximumRows, stopOnNull) + { + public override void DatasetRecord(DatabaseRecord dr) { } + } +} diff --git a/tests/CoreEx.Database.Test.Unit/OutboxDurationTests.cs b/tests/CoreEx.Database.Test.Unit/OutboxDurationTests.cs new file mode 100644 index 00000000..c95c902f --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/OutboxDurationTests.cs @@ -0,0 +1,31 @@ +using CoreEx.Database.Outbox; + +namespace CoreEx.Database.Test.Unit; + +[TestFixture] +public class OutboxDurationTests +{ + [TestCase(0, 1)] // Below the one-second minimum is clamped up, not down. + [TestCase(0.4, 1)] // Rounds away from zero to 0, then clamped to the 1-second minimum. + [TestCase(0.5, 1)] + [TestCase(1, 1)] + [TestCase(2.5, 3)] + [TestCase(300, 300)] // Regression: previously Math.Min(300, 1) collapsed every lease to 1 second. + public void ToSeconds(double totalSeconds, int expected) => OutboxDuration.ToSeconds(TimeSpan.FromSeconds(totalSeconds)).Should().Be(expected); + + [Test] + public void ToSeconds_NeverReturnsLessThanOne() => OutboxDuration.ToSeconds(TimeSpan.Zero).Should().Be(1); + + [TestCase(1, 2)] // 1s + max(1, round(0.1)) = 1 + 1 = 2. + [TestCase(5, 6)] // 5s + max(1, round(0.5)) = 5 + 1 = 6. + [TestCase(10, 11)] // 10s + max(1, round(1.0)) = 10 + 1 = 11. + [TestCase(300, 330)] // Regression: previously Math.Min(1, round(30)) collapsed the buffer to 1 second (301 total) instead of the intended 10% (330 total). + public void ToLeaseSecondsWithBuffer(int seconds, int expected) => OutboxDuration.ToLeaseSecondsWithBuffer(TimeSpan.FromSeconds(seconds)).Should().Be(expected); + + [Test] + public void ToLeaseSecondsWithBuffer_IsAlwaysGreaterThanToSeconds() + { + var duration = TimeSpan.FromMinutes(5); + OutboxDuration.ToLeaseSecondsWithBuffer(duration).Should().BeGreaterThan(OutboxDuration.ToSeconds(duration)); + } +} diff --git a/tools/validate-template-pack.ps1 b/tools/validate-template-pack.ps1 index ccb0bba3..39125b90 100644 --- a/tools/validate-template-pack.ps1 +++ b/tools/validate-template-pack.ps1 @@ -177,6 +177,65 @@ $testScenarios = @( } Build = $true }, + @{ + Name = "coreex-postgres-multiword" + Template = "coreex" + ProjectName = "Contoso.ProductCatalog" + Parameters = @{ + "data-provider" = "Postgres" + "messaging-provider" = "ServiceBus" + "refdata-enabled" = "true" + "outbox-enabled" = "true" + "rop-enabled" = "false" + } + TestPath = "test-coreex-postgres-multi" + Verify = @{ + # Migration filenames must be kebab-case; schema identifiers inside must be snake_case. + FilesPresent = @( + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-schema.pgsql" + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-outbox-tables.pgsql" + ) + FileContains = @{ + "tools/Contoso.ProductCatalog.Database/dbex.yaml" = "schema: product_catalog" + "tools/Contoso.ProductCatalog.Database/Program.cs" = '"product_catalog"' + "tools/Contoso.ProductCatalog.Database/Data/ref-data.seed.yaml" = "product_catalog:" + } + GlobFileContains = @{ + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-schema.pgsql" = '"product_catalog"' + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-outbox-tables.pgsql" = '"product_catalog"."outbox"' + } + } + Build = $false + }, + @{ + Name = "coreex-sqlserver-multiword" + Template = "coreex" + ProjectName = "Contoso.ProductCatalog" + Parameters = @{ + "data-provider" = "SqlServer" + "messaging-provider" = "ServiceBus" + "refdata-enabled" = "true" + "outbox-enabled" = "true" + "rop-enabled" = "false" + } + TestPath = "test-coreex-sqlserver-multi" + Verify = @{ + # Migration filenames must be kebab-case; schema identifiers inside must be PascalCase. + FilesPresent = @( + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-schema.sql" + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-outbox-tables.sql" + ) + FileContains = @{ + "tools/Contoso.ProductCatalog.Database/dbex.yaml" = "schema: ProductCatalog" + "tools/Contoso.ProductCatalog.Database/Program.cs" = '"ProductCatalog"' + } + GlobFileContains = @{ + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-schema.sql" = "[ProductCatalog]" + "tools/Contoso.ProductCatalog.Database/Migrations/*-create-product-catalog-outbox-tables.sql" = "[ProductCatalog].[Outbox]" + } + } + Build = $false + }, @{ Name = "coreex-no-data-provider" Template = "coreex" @@ -342,6 +401,23 @@ function Invoke-Assertion { } } } + + if ($Verify.GlobFileContains) { + foreach ($pattern in $Verify.GlobFileContains.Keys) { + $fullGlob = Join-Path $TestDir $pattern + $matched = Get-ChildItem $fullGlob -ErrorAction SilentlyContinue | Select-Object -First 1 + $needle = $Verify.GlobFileContains[$pattern] + if (-not $matched) { + Write-Fail "NO FILE MATCHING: $pattern" + $Failures.Value += "No file matching glob: $pattern" + } elseif ((Get-Content $matched.FullName -Raw).Contains($needle)) { + Write-Pass "Content '$needle': $($matched.Name)" + } else { + Write-Fail "CONTENT NOT FOUND '$needle' in $($matched.Name)" + $Failures.Value += "Expected '$needle' in glob-matched file: $pattern" + } + } + } } try { @@ -424,7 +500,8 @@ try { New-Item -ItemType Directory -Path $testDir -Force | Out-Null # Scaffold - $args = @("new", $scenario.Template, "--output", $testDir, "--name", "App", "--no-update-check") + $projectName = if ($scenario.ProjectName) { $scenario.ProjectName } else { "App" } + $args = @("new", $scenario.Template, "--output", $testDir, "--name", $projectName, "--no-update-check") foreach ($kv in $scenario.Parameters.GetEnumerator()) { $args += "--$($kv.Key)" if ($kv.Value -ne "") { $args += $kv.Value }